This commit is contained in:
Doxa Maps 2026-09-25 22:45:51 +00:00
parent 8e49ec41d6
commit 3cb89eae52
7 changed files with 205 additions and 28 deletions

View file

@ -288,7 +288,12 @@ Step by step instead: `bun run build` → `bun run cap:sync` → open `deploymen
The engine is a **runtime parameter** — same bundle, no rebuild. Resolution order
(first defined wins): `profile-config.provider` → `?provider=` in the page URL →
`window.MAP_PROVIDER` → default `mapbox`.
`window.MAP_PROVIDER` → default `auto`.
`auto` picks **Mapbox when a token is available** (a `tk` in the profile-config, `window.MAP_TOKEN`,
`?tk=`, a token pasted once in the tree, the studio's ⚙ Settings, or a configured token URL) and
**MapLibre otherwise** — keyless, with no request to Mapbox. The repo ships no Mapbox token, so a
fresh checkout draws every map with MapLibre until you give it one. An explicit `provider` always wins.
```html
<!-- per embed -->
@ -299,8 +304,8 @@ https://<cdn>/dx-maps/<bundle>/<profile>/index.html?provider=maplibre
```
`maplibre` runs token-free on the OpenFreeMap style (the right GL script/CSS is injected
automatically); the geocoder search bar keeps working in both engines. `mapbox` (the default)
behaves exactly as before — existing embeds never change.
automatically); the geocoder search bar keeps working in both engines. `mapbox` behaves exactly
as before, and an embed that passes a token keeps getting Mapbox under `auto`.
> Status: **on `main`.** Generated pages honor `provider:'maplibre'` end-to-end — the page
> loads the MapLibre peer instead of Mapbox and **skips the token ladder entirely** (no

View file

@ -0,0 +1,134 @@
#!/usr/bin/env node
/**
* check-auto-engine — the default engine is 'auto': Mapbox WHEN a token is available, MapLibre otherwise.
*
* WHY (2026-09-25, by project decision). The public repo carries no Mapbox token. With a default of
* 'mapbox', a keyless visitor got a map that could not draw and a paste-a-token prompt, and the only
* way out was a token in the repo. 'auto' asks the token ladder instead (useMapProvider.js), and the
* generated leaf asks the same ladder before it mounts (generate-tree.mjs, PROVIDER).
*
* THREE CASES, on one map leaf of THIS tree (or --dir), each in a fresh browser context:
* 1 NO TOKEN the map draws with MapLibre, makes ZERO requests to any *.mapbox.com host, and
* PAINTS (distinct colours on the canvas, not a blank rectangle).
* 2 HOST TOKEN the host sets window.MAP_TOKEN before the page runs: the map draws with Mapbox,
* and THAT token is the access_token on its requests to Mapbox.
* 3 EXPLICIT WINS the same host token, with ?provider=maplibre: MapLibre, zero Mapbox requests.
* The token in case 2 is a well-formed FAKE, built at run time; Mapbox requests that carry it are
* recorded and aborted, never sent. The engine is read from the canvas that drew
* (mapboxgl-canvas / maplibregl-canvas), not from which globals happen to exist.
*
* USAGE node internal/check-auto-engine.mjs [--dir <tree root>] [--leaf <bundle>/<profile>]
* Run through internal/browser-slot.sh.
* EXIT 0 all three hold · 1 one does not · 75 not measured.
*/
import { chromium } from 'playwright'
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
import { existsSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { TREE_NAME } from './_tree-name.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const argv = process.argv.slice(2)
const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d }
const DIR = resolve(arg('--dir', resolve(ROOT, 'public')))
const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map')
const MIN_COLOURS = 40
const MAPBOX_HOST = /(^|\.)mapbox\.com$/i
// A fake public token of the right SHAPE (pk.<base64url JSON>.<sig>), so the engine accepts it.
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
const FAKE_TOKEN = ['pk', b64({ u: 'auto-engine-check', a: 'not-a-real-token' }), 'c2lnbmF0dXJl'].join('.')
if (!existsSync(join(DIR, TREE_NAME, LEAF, 'index.html'))) { console.log(`AUTO-ENGINE: NOT MEASURED — no ${LEAF} under ${DIR}/${TREE_NAME}/`); process.exit(75) }
const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) })
const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' })
const stop = () => { try { srv.kill() } catch { /* gone */ } }
let up = false
for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) }
if (!up) { stop(); console.log(`AUTO-ENGINE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) }
console.log(`AUTO-ENGINE: measuring THIS TREE — ${DIR} (127.0.0.1:${port}), leaf ${LEAF}`)
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
async function measure({ hostToken = '', query = '' }) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
const mapbox = []
// The engine script itself may come from Mapbox's CDN; everything that CARRIES a token is recorded
// and aborted, so the fake token never leaves this machine.
await page.route((url) => MAPBOX_HOST.test(url.hostname), (route) => {
const u = new URL(route.request().url())
mapbox.push(u)
if (u.searchParams.has('access_token')) return route.abort()
return route.continue()
})
if (hostToken) await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, hostToken)
try {
await page.goto(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html${query}`, { waitUntil: 'load', timeout: 90000 })
const deadline = Date.now() + 45000
let engine = ''
while (Date.now() < deadline && !engine) {
engine = await page.evaluate(() => {
const roots = [document]
for (const el of document.querySelectorAll('*')) if (el.shadowRoot) roots.push(el.shadowRoot)
for (const r of roots) {
if (r.querySelector('canvas.maplibregl-canvas')) return 'maplibre'
if (r.querySelector('canvas.mapboxgl-canvas')) return 'mapbox'
}
return ''
})
if (!engine) await page.waitForTimeout(500)
}
const prompt = await page.evaluate(() => !!document.getElementById('tkIn'))
let colours = 0
if (engine === 'maplibre') {
await page.waitForTimeout(8000) // tiles
// Straight from CDP: page.screenshot waits for web fonts first and timed out on this leaf.
const cdp = await ctx.newCDPSession(page)
const png = Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png' })).data, 'base64')
colours = await page.evaluate(async (data) => {
const img = new Image(); img.src = 'data:image/png;base64,' + data; await img.decode()
const c = document.createElement('canvas'); c.width = img.width; c.height = img.height
const g = c.getContext('2d'); g.drawImage(img, 0, 0)
const d = g.getImageData(0, 0, c.width, c.height).data, seen = new Set()
for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 10) | ((d[i + 1] >> 3) << 5) | (d[i + 2] >> 3))
return seen.size
}, png.toString('base64'))
} else {
await page.waitForTimeout(3000)
}
return { engine: engine || 'none', prompt, colours, mapbox }
} catch (e) {
return { err: String(e.message).split('\n')[0].slice(0, 120), mapbox }
} finally { await ctx.close() }
}
const results = []
try {
results.push({ name: '1 no token', r: await measure({}) })
results.push({ name: '2 host token', r: await measure({ hostToken: FAKE_TOKEN }) })
results.push({ name: '3 explicit maplibre + token', r: await measure({ hostToken: FAKE_TOKEN, query: '?provider=maplibre' }) })
} finally { await browser.close(); stop() }
let bad = 0, unmeasured = 0
const say = (ok, name, msg) => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(30)} ${msg}`); if (!ok) bad++ }
for (const { name, r } of results) {
if (r.err) { console.log(` ?? ${name.padEnd(30)} NOT MEASURED — ${r.err}`); unmeasured++; continue }
const withToken = r.mapbox.filter((u) => u.searchParams.has('access_token'))
const summary = `engine ${r.engine}, ${r.mapbox.length} Mapbox request(s)${r.prompt ? ', TOKEN PROMPT shown' : ''}`
if (name.startsWith('1')) {
say(r.engine === 'maplibre' && r.mapbox.length === 0 && r.colours >= MIN_COLOURS && !r.prompt, name,
`${summary}, ${r.colours} colours (floor ${MIN_COLOURS}) — want maplibre, 0 Mapbox requests, painted, no prompt`)
} else if (name.startsWith('2')) {
const carries = withToken.length > 0 && withToken.every((u) => u.searchParams.get('access_token') === FAKE_TOKEN)
say(r.engine === 'mapbox' && carries, name,
`${summary}, ${withToken.length} carry a token${withToken.length && !carries ? ' — NOT the host token' : ''} — want mapbox, the host token on its requests`)
} else {
say(r.engine === 'maplibre' && r.mapbox.length === 0, name, `${summary} — want maplibre, 0 Mapbox requests`)
}
}
if (bad) { console.log(`AUTO-ENGINE: FAIL — ${bad} of 3 case(s) do not hold.`); process.exit(1) }
if (unmeasured) { console.log(`AUTO-ENGINE: NOT MEASURED — ${unmeasured} case(s) could not be judged.`); process.exit(75) }
console.log('AUTO-ENGINE: PASS — no token draws MapLibre keyless; a host token draws Mapbox with that token; an explicit provider wins.')

View file

@ -801,7 +801,7 @@ function mapPageHtml(opts) {
geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time —
so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox
(4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider
→ ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load
→ ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load
exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the
geocoder's on-demand loader — the path composite leaves already run on. Gate:
check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->`
@ -868,11 +868,8 @@ ${apiScript}
var BASE_CONFIG = ${JSON.stringify(pageConfig)};
// MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token:
// the ladder below is skipped entirely and the element mounts immediately.
// …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER —
// the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public
// copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use.
var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase();
var TOKEN_FREE = ${JSON.stringify(tokenFree)} || RUNTIME_PROVIDER === 'maplibre';
// (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.)
var TOKEN_FREE = ${JSON.stringify(tokenFree)};
// A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN
// rather than this page's profile-config. With no baked token (the public copy), it must get
// one from ?tk= or a pasted token, or be asked for one — see the rungs below.
@ -882,6 +879,8 @@ ${apiScript}
// Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts
// a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token —
// useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty.
// A token the HOST set before this page ran outranks the baked one (measured: it used to lose).
var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || '';
if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; }
var DEFAULT_TOKEN_URL = '/api/maps/token';
@ -949,13 +948,24 @@ ${apiScript}
try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {}
var urlTk = qp('tk');
if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} }
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN;
var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN;
// PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js):
// profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins.
// 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token
// endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The
// element decides the same way from the token this page hands it, so the two cannot disagree.
var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim();
var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : '';
var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN));
var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL'));
if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; }
if (TOKEN_FREE) {
if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; }
mount('');
// A token-free composite with no token anywhere: its content renders, but its nested maps
// cannot. Ask once; the token is stored for the whole tree and the page reloads with it.
if (NESTS_MAPS && !directToken) {
// Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead.
if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') {
var bar = document.createElement('div');
bar.id = 'status';
bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)');
@ -986,7 +996,9 @@ ${apiScript}
function fetchToken(i, errors) {
if (i >= candidates.length) {
// Every endpoint failed: say which, and still offer the paste-once box (no dead ends).
// Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless.
if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); }
// Mapbox was asked for: say which endpoints failed, and still offer the paste-once box.
askToken();
if (status) {
var d = document.createElement('div');
@ -1276,10 +1288,11 @@ function scanBundles() {
// <script src>, and a preload is only reused when its request mode matches — with crossorigin it
// would download twice. Mapbox only: a non-map profile gets none, and a partner switching to
// provider:"maplibre" is told to drop the line, since that engine must make zero Mapbox requests.
// The default engine is 'auto' (Mapbox only with a token), so a partner with no token drops it too.
embed: {
iframe: iframeEmbed,
script: isMap && MAPBOX_GL_JS
? `<!-- Mapbox engine, fetched early; remove this line if you set "provider":"maplibre" -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed
? `<!-- Mapbox engine, fetched early. Keep it only with a real Mapbox token in "tk": without one (or with "provider":"maplibre") the map draws with MapLibre and this line must go -->\n<link rel="preload" as="script" href="${MAPBOX_GL_JS}">\n` + scriptEmbed
: scriptEmbed,
},
...(instances.length ? { instances } : {}),

View file

@ -285,7 +285,7 @@ export function useMapInstance({
// 'style.load') cannot do — see useMapTheme.bootConfig. Styles without imports ignore it;
// NOT forwarded to MapLibre. Default undefined → byte-for-byte the previous behaviour.
config = undefined,
// provider: 'mapbox' (default) | 'maplibre'. Selects the GL engine.
// provider: 'mapbox' | 'maplibre' | unset ('auto', see useMapProvider.js). Selects the GL engine.
//
// ★ LEFT UNSET, THE PAGE DECIDES — THAT IS WHAT MAKES ONE SWITCH FLIP EVERY MAP.
// This used to default to the LITERAL 'mapbox'. resolveMapProvider has long supported a
@ -296,7 +296,8 @@ export function useMapInstance({
// Resolving here instead moves the decision to the one place every map already goes through, so
// a partner running thousands of embeds sets ONE value and the whole tree follows.
// An explicit caller still wins outright (prayer-walk-planner pins 'maplibre' deliberately), and
// with no page switch set resolveMapProvider returns 'mapbox' — so the default is unchanged.
// With no switch set, resolveMapProvider decides 'auto': Mapbox when a token is available (this
// call's accessToken counts), MapLibre when none is.
provider = undefined,
center = [20, 10],
zoom = 2,
@ -363,7 +364,7 @@ export function useMapInstance({
// degrades to the page switch instead of throwing.
let _pcForProvider = null
try { _pcForProvider = inject('profileConfig', null) } catch { /* called outside setup */ }
provider = provider || resolveMapProvider(_pcForProvider)
provider = provider || resolveMapProvider(_pcForProvider, { token: accessToken })
const isMaplibre = provider === 'maplibre'
// THE READER'S LANGUAGE, for the basemap's place names (maps/localize-labels.js). ProfileLoader

View file

@ -6,20 +6,29 @@
* 2. ?provider= in the page URL — read here directly, so any generated leaf page can be
* flipped from the URL without the host doing anything
* 3. window.MAP_PROVIDER — host global, for a host that sets it programmatically
* 4. 'mapbox' — default; existing behaviour, never regresses
* 4. 'auto' — the default: Mapbox WHEN A TOKEN IS AVAILABLE, else MapLibre
*
* Valid values: 'mapbox' | 'maplibre'. Anything else falls back to the default.
* Valid values: 'mapbox' | 'maplibre' | 'auto'. Anything else is treated as 'auto'.
*
* ★ This header previously listed only THREE rungs, omitting ?provider= and numbering
* window.MAP_PROVIDER as step 2 — it described a ladder the function does not implement.
* documentation/how-to.md ("Swap the map engine") carries the correct four-rung order; keep the two
* in sync, and treat THIS function as the authority when they disagree.
* ★ 'auto' (2026-09-25, by project decision). The public repo carries NO Mapbox token, so a default
* of 'mapbox' gave every keyless visitor a map that could not draw and a paste-a-token prompt.
* 'auto' asks the token ladder instead: a token in this element's profile-config (`tk`), or one
* published page-wide (window.MAP_TOKEN — the generated leaf's ?tk= / pasted / baked / fetched rungs,
* the studio's ⚙ Settings, and any element handed a `tk` all land there, see useSharedMapToken)
* means Mapbox, exactly as before. No token anywhere means MapLibre, keyless, with zero requests to
* Mapbox. An explicit provider (rungs 1-3) always wins.
* The decision is taken when the map is created: a token that arrives LATER does not swap a map
* that already drew with MapLibre (a host that fetches its token should set it before mounting, or
* set provider:'mapbox' explicitly).
*
* documentation/how-to.md ("Swap the map engine") carries the same order; keep the two in sync, and
* treat THIS function as the authority when they disagree.
*/
import { inject } from 'vue'
export const MAP_PROVIDERS = ['mapbox', 'maplibre']
export const DEFAULT_PROVIDER = 'mapbox'
export const DEFAULT_PROVIDER = 'auto'
/** Unwrap a ref/computed to its config object; pass plain objects through. */
function unwrapConfig(profileConfig) {
@ -30,18 +39,33 @@ function unwrapConfig(profileConfig) {
return profileConfig
}
/**
* Is a Mapbox token available to this map? The element's own `tk`, or the page-wide token
* (window.MAP_TOKEN, which useSharedMapToken bridges to every element's published tk).
*/
export function hasMapToken(cfg) {
// The embed snippets ship tk:"YOUR_MAPBOX_TOKEN" as a placeholder; an unfilled one is not a token.
const real = (v) => typeof v === 'string' && !!v.trim() && !/^YOUR_/.test(v.trim())
if (real(cfg?.tk)) return true
if (typeof window === 'undefined') return false
return real(window.MAP_TOKEN) || real(window.__doxaMapToken)
}
/**
* Resolve the provider from a profile-config (object or ref) + window fallback.
* Always returns a concrete engine ('mapbox' | 'maplibre'); 'auto' is decided here. `token` is a
* token the caller holds outside the config (useMapInstance's accessToken); it counts as available.
* Pure — safe to call outside a Vue setup().
*/
export function resolveMapProvider(profileConfig) {
export function resolveMapProvider(profileConfig, { token } = {}) {
const cfg = unwrapConfig(profileConfig)
const fromConfig = cfg?.provider
const fromUrl = typeof location !== 'undefined'
? new URLSearchParams(location.search).get('provider') : undefined
const fromWindow = typeof window !== 'undefined' ? window.MAP_PROVIDER : undefined
const raw = String(fromConfig || fromUrl || fromWindow || DEFAULT_PROVIDER).toLowerCase().trim()
return MAP_PROVIDERS.includes(raw) ? raw : DEFAULT_PROVIDER
if (MAP_PROVIDERS.includes(raw)) return raw
return hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : 'maplibre'
}
/** Human-readable engine label for a badge / UI. */

View file

@ -118,8 +118,8 @@ const mapContainer = ref(null)
// 1. profile-config.provider — this embed, decided by whoever mounted the element
// 2. ?provider= / window.MAP_PROVIDER — the page, i.e. "swap every map on this site"
// 3. dashboard.config.js — this bundle's shipped default (Mapbox, by the ruling above)
// With no switch set, resolveMapProvider returns 'mapbox' and step 2 cannot fire, so the shipped
// behaviour is byte-for-byte unchanged.
// With no switch set, resolveMapProvider decides 'auto': with a token step 2 does not fire and the
// shipped default holds; with NO token it answers 'maplibre', because Mapbox cannot draw without one.
const mapProvider = computed(() => {
const fromElement = profileConfig?.value?.provider
if (fromElement === 'maplibre' || fromElement === 'mapbox') return fromElement

View file

@ -5,7 +5,7 @@
"embedBaseUrl": "",
"assetsBaseUrl": "",
"tokenUrl": "",
"token": "pk.eyJ1IjoiZ29zeXN0ZW1zIiwiYSI6ImNtZm9vM291czA3amQya3BzYW42YW9ibTcifQ.52WM0IpxY9FcB0zc9f1zbQ",
"token": "",
"dropin": {
"dest": "../doxa-life/public/embed/doxa-maps-build",
"include": [