Engine and token props: a token-service URL is honoured, Clear clears, and a pending token is awaited

(cherry picked from commit 6e4ddceced)
This commit is contained in:
Doxa Maps 2026-09-26 00:44:15 +00:00
parent 05165d9b26
commit 0c7f4af47b
7 changed files with 196 additions and 21 deletions

View file

@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* check-map-props-unit — the engine and token props, decided in the library, measured WITHOUT a browser.
*
* resolveMapProvider ('auto': Mapbox when a token is available, MapLibre otherwise; an explicit provider
* wins), and the page-wide token (useSharedMapToken): publish, CLEAR, and a token-service URL. Runs the
* real library modules in Node against a stand-in window/location, so it is fast and needs no browser.
*
* Each case names the defect it guards (U3 = Clear left the old token live, U1 = tokenUrl read by nothing,
* U7 = mixed-case provider dropped).
*
* USAGE node internal/check-map-props-unit.mjs EXIT 0 pass · 1 fail
*/
const listeners = {}
globalThis.window = globalThis
globalThis.CustomEvent = class { constructor(type, o) { this.type = type; this.detail = o && o.detail } }
window.addEventListener = (t, f) => { (listeners[t] = listeners[t] || []).push(f) }
window.removeEventListener = (t, f) => { listeners[t] = (listeners[t] || []).filter((x) => x !== f) }
window.dispatchEvent = (e) => { for (const f of listeners[e.type] || []) f(e); return true }
let search = ''
Object.defineProperty(globalThis, 'location', { configurable: true, get: () => ({ search }) })
const { resolveMapProvider, hasMapToken, explicitMapProvider, DEFAULT_PROVIDER } = await import('../library/composables/useMapProvider.js')
const shared = await import('../library/composables/useSharedMapToken.js')
let bad = 0
const eq = (name, got, want) => { const ok = got === want; if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(64)} ${JSON.stringify(got)}${ok ? '' : ` (want ${JSON.stringify(want)})`}`) }
const reset = () => { search = ''; delete window.MAP_PROVIDER; shared.clearMapToken(); window.__doxaMapTokenPending = null }
const TK = ['pk', 'eyJ1IjoidGVzdCJ9', 'c2ln'].join('.')
console.log('MAP-PROPS: the library, in Node (no browser)')
eq('default is auto', DEFAULT_PROVIDER, 'auto')
reset(); eq('auto, no token anywhere → maplibre', resolveMapProvider({}), 'maplibre')
reset(); eq('auto, tk in profile-config → mapbox', resolveMapProvider({ tk: TK }), 'mapbox')
reset(); eq('auto, placeholder tk YOUR_MAPBOX_TOKEN → maplibre', resolveMapProvider({ tk: 'YOUR_MAPBOX_TOKEN' }), 'maplibre')
reset(); window.MAP_TOKEN = TK; eq('auto, host window.MAP_TOKEN → mapbox', resolveMapProvider({}), 'mapbox')
reset(); eq('auto, a token the caller holds (useMapInstance) → mapbox', resolveMapProvider({}, { token: TK }), 'mapbox')
reset(); eq('auto, tokenUrl in profile-config → mapbox (U1)', resolveMapProvider({ tokenUrl: 'https://example.org/token' }), 'mapbox')
reset(); eq('explicit maplibre with a token → maplibre', resolveMapProvider({ provider: 'maplibre', tk: TK }), 'maplibre')
reset(); eq('explicit mapbox without a token → mapbox', resolveMapProvider({ provider: 'mapbox' }), 'mapbox')
reset(); eq('mixed case "MapLibre" is honoured (U7)', resolveMapProvider({ provider: 'MapLibre', tk: TK }), 'maplibre')
reset(); search = '?provider=maplibre'; window.MAP_TOKEN = TK; eq('?provider=maplibre wins over a host token', resolveMapProvider({}), 'maplibre')
reset(); window.MAP_PROVIDER = 'mapbox'; eq('window.MAP_PROVIDER is explicit', explicitMapProvider({}), 'mapbox')
reset(); eq('"auto" is not explicit', explicitMapProvider({ provider: 'auto' }), '')
// ── the page-wide token ──
reset()
const tok = shared.useSharedMapToken()
shared.publishMapToken(TK)
eq('publish reaches window.MAP_TOKEN', window.MAP_TOKEN, TK)
eq('publish reaches a live element (ref)', tok.value, TK)
shared.clearMapToken()
eq('CLEAR empties window.MAP_TOKEN (U3)', window.MAP_TOKEN || '', '')
eq('CLEAR reaches a live element (U3)', tok.value, '')
eq('after CLEAR, auto → maplibre (U3)', resolveMapProvider({}), 'maplibre')
window.MAP_TOKEN = TK
eq('after CLEAR, a host assignment is adopted again (bridge intact, U3)', tok.value, TK)
eq('hasMapToken sees it', hasMapToken({}), true)
// The OLD Clear path: `delete window.MAP_TOKEN` + publishMapToken('') leaves the old token live (publishing
// '' is a no-op by design, which is why Clear needs clearMapToken). It also used to remove the bridge for
// good; the bridge now re-installs itself, so a later host token is adopted again.
reset(); shared.publishMapToken(TK)
delete window.MAP_TOKEN; shared.publishMapToken('')
eq('reproduction: the old Clear left the OLD token live on elements', tok.value, TK)
window.MAP_TOKEN = 'pk.other'
eq('self-heal: after that delete, a later host token reaches elements again', tok.value, 'pk.other')
window.__doxaMapToken = ''; window.MAP_TOKEN = ''
// ── token-service URL (U1) ──
reset()
globalThis.fetch = async (url) => ({ ok: true, headers: { get: () => 'application/json' }, json: async () => ({ token: url.includes('good') ? TK : 'sk.secret' }) })
const got = await shared.fetchMapToken('https://example.org/good')
eq('tokenUrl answer is published page-wide (U1)', window.MAP_TOKEN, TK)
eq('fetchMapToken resolves with it', got, TK)
reset()
const secret = await shared.fetchMapToken('https://example.org/bad')
eq('a non-public (sk.) answer is refused', secret, '')
eq('…and nothing is published', window.MAP_TOKEN || '', '')
if (bad) { console.log(`MAP-PROPS: FAIL — ${bad} case(s).`); process.exit(1) }
console.log('MAP-PROPS: PASS — auto picks the engine from the token ladder, explicit wins, Clear clears, tokenUrl is honoured.')

View file

@ -15,7 +15,7 @@
*/
import { computed, provide, inject, watchEffect, watch } from 'vue'
import { useSharedMapToken, publishMapToken } from './composables/useSharedMapToken.js'
import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js'
import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js'
import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale } from './i18n/index.js'
// QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds
@ -127,6 +127,11 @@ const mapboxToken = computed(() => {
// that a token belongs to the page, not to whichever component happened to receive it. Publishing
// is idempotent and ignores empties, so this cannot clobber a good token with a blank one.
watch(() => config.value?.tk, (tk) => { if (typeof tk === 'string' && tk) publishMapToken(tk) }, { immediate: true })
// A token-SERVICE URL in the profile-config (tokenUrl) is asked only when no token is at hand; its answer
// is published page-wide, so mapboxToken above (and every sibling) picks it up.
watch(() => config.value?.tokenUrl, (url) => {
if (url && !config.value?.tk && !sharedToken.value) fetchMapToken(url)
}, { immediate: true })
// Default to the CONFIGURED active source from sources.json (e.g. 'pray-tools', the live API)
// — NOT a hardcoded 'doxa-csv'. A host/gem-frame that embeds the bundle without an explicit
// profile-config.dataSource (the platform case) was silently falling back to 'doxa-csv', whose

View file

@ -75,7 +75,7 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
// Named styles: the 12-style pack. A profile-config style of 'ink' /
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
import { resolveMapProvider } from './useMapProvider.js'
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js'
import { installRtlTextPlugin } from '../maps/rtl-text.js'
import { localizeBasemapLabels } from '../maps/localize-labels.js'
@ -364,8 +364,11 @@ export function useMapInstance({
// degrades to the page switch instead of throwing.
let _pcForProvider = null
try { _pcForProvider = inject('profileConfig', null) } catch { /* called outside setup */ }
// 'auto' decided it (nobody asked for an engine): it may still change once, below, if the token it
// counted on was a token SERVICE that answers with nothing.
const autoProvider = !provider && !explicitMapProvider(_pcForProvider)
provider = provider || resolveMapProvider(_pcForProvider, { token: accessToken })
const isMaplibre = provider === 'maplibre'
let isMaplibre = provider === 'maplibre'
// THE READER'S LANGUAGE, for the basemap's place names (maps/localize-labels.js). ProfileLoader
// provides it as a ref ('lang'); an element without ProfileLoader falls back to its profile-config
@ -386,6 +389,13 @@ export function useMapInstance({
async function initializeMap() {
// Resolve a NAMED style ('ink', 'satellite', …) for this engine; raw URLs
// and undefined keep their existing behavior (see library/styles/registry.js).
// 'auto' picked Mapbox on a token service still answering: wait for it. No token after all → MapLibre,
// keyless, which is what 'auto' means without a token (a map that cannot draw is not a fallback).
if (autoProvider && !isMaplibre && !accessToken && typeof window !== 'undefined' && window.__doxaMapTokenPending) {
await window.__doxaMapTokenPending
if (!containerRef.value) return
if (!(typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN)) { provider = 'maplibre'; isMaplibre = true }
}
const _named = resolveNamedStyle(style, provider)
const effectiveStyle = _named.style || style
if (!containerRef.value) {
@ -422,6 +432,11 @@ export function useMapInstance({
// mounts a map component directly, with no ProfileLoader / profile-config wrapper) may expose the
// token once as `window.MAP_TOKEN`. Any map on the page then finds it. Per-instance accessToken
// still wins; this only fills the gap when none was passed.
// A token service still answering (profile-config.tokenUrl, see fetchMapToken): wait for it.
if (!accessToken && typeof window !== 'undefined' && window.__doxaMapTokenPending) {
await window.__doxaMapTokenPending
if (gone()) return
}
if (!accessToken && typeof window !== 'undefined' && typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) {
accessToken = window.MAP_TOKEN
}

View file

@ -47,8 +47,10 @@ 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
// A token service to ask (profile-config.tokenUrl) counts: the element fetches it before the map starts.
if (/^https?:\/\//i.test(String(cfg?.tokenUrl || ''))) return true
if (typeof window === 'undefined') return false
return real(window.MAP_TOKEN) || real(window.__doxaMapToken)
return real(window.MAP_TOKEN) || real(window.__doxaMapToken) || !!window.__doxaMapTokenPending
}
/**
@ -58,14 +60,19 @@ export function hasMapToken(cfg) {
* Pure — safe to call outside a Vue setup().
*/
export function resolveMapProvider(profileConfig, { token } = {}) {
const cfg = unwrapConfig(profileConfig)
return explicitMapProvider(cfg) || (hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : 'maplibre')
}
/** The engine someone ASKED for (rungs 1-3), or '' when the default ('auto') decides. */
export function explicitMapProvider(profileConfig) {
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()
if (MAP_PROVIDERS.includes(raw)) return raw
return hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : 'maplibre'
return MAP_PROVIDERS.includes(raw) ? raw : ''
}
/** Human-readable engine label for a badge / UI. */

View file

@ -52,6 +52,7 @@ const readStored = () => {
/** Announce a token to every element on the page. No-ops on an empty or unchanged value. */
export function publishMapToken(tk) {
if (typeof window === 'undefined') return ''
installBridge()
const next = typeof tk === 'string' ? tk.trim() : ''
if (!next || next === readStored()) return readStored()
window[KEY] = next
@ -59,6 +60,20 @@ export function publishMapToken(tk) {
return next
}
/**
* Withdraw the page-wide token (a "Clear" in the studio). publishMapToken('') is deliberately a no-op, and
* `delete window.MAP_TOKEN` removed the bridge's accessor while its install flag stayed set, so the bridge
* never came back and the OLD token stayed live. This empties the value through the same channel.
*/
export function clearMapToken() {
if (typeof window === 'undefined') return
const had = readStored() || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN)
installBridge()
window[KEY] = ''
if (!Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) { try { window.MAP_TOKEN = '' } catch { /* host-owned */ } }
if (had) { try { window.dispatchEvent(new CustomEvent(EVENT, { detail: '' })) } catch { /* pre-CustomEvent host */ } }
}
/**
* Adopt `window.MAP_TOKEN` as a source, in BOTH directions and for the whole life of the page.
*
@ -68,7 +83,10 @@ export function publishMapToken(tk) {
* because N bundles on one page would otherwise stack N accessors on the same property.
*/
function installBridge() {
if (typeof window === 'undefined' || window[FLAG]) return
if (typeof window === 'undefined') return
// Installed AND still in place: a host (the studio's old Clear did) can `delete window.MAP_TOKEN`, which
// removes the accessor while the flag stays set — then nothing was ever adopted again. Re-install.
if (window[FLAG] && Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) return
// Seed from whatever the page already set before this bundle loaded.
const existing = typeof window.MAP_TOKEN === 'string' ? window.MAP_TOKEN : ''
if (existing && !readStored()) window[KEY] = existing
@ -100,3 +118,30 @@ export function useSharedMapToken() {
}
return token
}
/**
* profile-config.tokenUrl: a token SERVICE the element asks when it was handed no token (the studio's
* header and the gallery write it; nothing used to read it). Same contract as the leaf pages' ladder:
* JSON {token|tk|accessToken} or a bare string, and only a PUBLIC token (pk.…) is accepted. One fetch per
* URL per page; the answer is published page-wide. While it is in flight, window.__doxaMapTokenPending
* holds the promise, so a map about to start on Mapbox waits for it instead of failing tokenless.
* @returns {Promise<string>} the token, or '' when the service did not give one
*/
export function fetchMapToken(url) {
if (typeof window === 'undefined' || !/^https?:\/\//i.test(String(url || ''))) return Promise.resolve('')
const cache = (window.__doxaMapTokenFetches = window.__doxaMapTokenFetches || {})
if (cache[url]) return cache[url]
const p = fetch(url)
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return (r.headers.get('content-type') || '').includes('json') ? r.json() : r.text() })
.then((d) => {
const tk = String((typeof d === 'string' ? d : d && (d.token || d.tk || d.accessToken)) || '').trim()
if (!tk.startsWith('pk.')) return ''
publishMapToken(tk)
return tk
})
.catch((e) => { console.warn(`[map token] ${url}: ${e && e.message ? e.message : e}`); return '' })
.finally(() => { if (window.__doxaMapTokenPending === p) window.__doxaMapTokenPending = null })
window.__doxaMapTokenPending = p
cache[url] = p
return p
}

View file

@ -32,7 +32,12 @@ export const LANGUAGES = [
// Kept here, flagged, so the list stays a faithful mirror of the source of truth.
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', hideFromSwitcher: true },
{ code: 'hi', name: 'Hindi', nativeName: 'हिन्दी', flag: '🇮🇳', hideFromSwitcher: true },
// enabled:false in dx-life (routable/translatable but not offered): it, zh, ro.
// enabled:false in dx-life (routable/translatable but not offered), but every library catalogue carries
// them (library/i18n SUPPORTED_LOCALES, 11 codes). Listed, hidden from the public switcher, so a tag of
// it/ro/zh is no longer normalised to 'en' — it was, in the studio (11 locales vs 8 here).
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', hideFromSwitcher: true },
{ code: 'ro', name: 'Romanian', nativeName: 'Română', flag: '🇷🇴', hideFromSwitcher: true },
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', hideFromSwitcher: true },
]
/** Languages offered in the dropdown. */

View file

@ -203,7 +203,8 @@ import { defineComponent, inject } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import VerticalTabsLayout from '@map/layouts/VerticalTabsLayout.vue'
import StudioTree from './src/StudioTree.vue'
import { SWITCHER_LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { LANGUAGES, loadLocale, saveLocale, getLanguage, normaliseLocale } from '@map/layouts/app-wrapper-material/languages.js'
import { publishMapToken, clearMapToken } from '@map/composables/useSharedMapToken.js'
// The reader's own map token / token-service URL. localStorage only: never written into the build,
// never sent anywhere but Mapbox (the token) or the URL the reader typed (the service).
@ -503,7 +504,9 @@ export default defineComponent({
userTokenUrl: readStored(TOKEN_URL_KEY),
fetchedTk: '',
bakedTk: '',
switcherLangs: SWITCHER_LANGUAGES,
hostProvider: '',
// EVERY locale the library carries, not the public site's six: the studio exists to exercise props.
switcherLangs: LANGUAGES,
// rows keyed by "<bundle>/<profile>"; each carries its resolved nesting state.
rows: [], // flat list built from the manifest (grouped for display via `groups`)
// raw manifest bundles, kept ONLY so composeGroups() can read each bundle's `composes` list —
@ -648,7 +651,8 @@ export default defineComponent({
return this.userTk || this.fetchedTk || this.tk || this.cfg.tk || this.bakedTk || ''
},
effectiveProvider() {
const p = this.userProvider || this.provider || this.cfg.provider || ''
// Case-insensitive, as resolveMapProvider is: a host's provider="MapLibre" was dropped here.
const p = String(this.userProvider || this.provider || this.cfg.provider || '').toLowerCase().trim()
return p === 'maplibre' || p === 'mapbox' ? p : ''
},
engines() {
@ -687,9 +691,16 @@ export default defineComponent({
mounted() {
// Read once: after applyToken sets window.MAP_TOKEN it would no longer be "the page's own".
this.bakedTk = (typeof window !== 'undefined' && window.MAP_TOKEN) || ''
if (this.userProvider) this.publishProvider()
// The HOST's own engine switch, kept so "each map's own" restores it instead of deleting it.
this.hostProvider = (typeof window !== 'undefined' && typeof window.MAP_PROVIDER === 'string') ? window.MAP_PROVIDER : ''
this.publishProvider() // a host provider (attribute/profile-config) reaches nested maps too, chosen or not
if (this.userTk) this.publishToken()
else if (this.userTokenUrl) this.fetchToken(this.userTokenUrl).then((t) => { if (t) { this.fetchedTk = t; this.publishToken() } }).catch(() => {})
else if (this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl) {
// A token from a URL arrives AFTER the first pane mounted; re-mount so that pane gets it too.
this.fetchToken(this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl)
.then((t) => { if (t && t !== this.fetchedTk) { this.fetchedTk = t; this.publishToken(); this.remountPanes() } })
.catch(() => {})
}
this.loadManifest()
},
methods: {
@ -745,6 +756,7 @@ export default defineComponent({
publishProvider() {
if (typeof window === 'undefined') return
if (this.effectiveProvider) window.MAP_PROVIDER = this.effectiveProvider
else if (this.hostProvider) window.MAP_PROVIDER = this.hostProvider
else if ('MAP_PROVIDER' in window) delete window.MAP_PROVIDER
},
clearToken() {
@ -758,8 +770,9 @@ export default defineComponent({
// so the effective token is published there too (and the page's own token restored on clear).
publishToken() {
if (typeof window === 'undefined') return
if (this.effectiveTk) window.MAP_TOKEN = this.effectiveTk
else if ('MAP_TOKEN' in window) delete window.MAP_TOKEN
// Through useSharedMapToken, never `delete`: deleting removed its bridge for good (U3).
if (this.effectiveTk) { publishMapToken(this.effectiveTk); if (window.MAP_TOKEN !== this.effectiveTk) window.MAP_TOKEN = this.effectiveTk }
else clearMapToken()
},
// Release every mounted pane (frees its map) and re-mount the one on screen with the new token;
// the others pick it up when they are next selected.
@ -803,7 +816,8 @@ export default defineComponent({
paneConfig(row, locale) {
const pc = { profile: row.profile, instanceId: 'ps-' + row.key.replace(/\//g, '-'), locale }
if (this.effectiveTk) pc.tk = this.effectiveTk
if (this.tokenUrl || this.cfg.tokenUrl) pc.tokenUrl = this.tokenUrl || this.cfg.tokenUrl
const tokenUrl = this.userTokenUrl || this.tokenUrl || this.cfg.tokenUrl
if (tokenUrl) pc.tokenUrl = tokenUrl
if (this.effectiveProvider) pc.provider = this.effectiveProvider
if (this.base) pc.treeBase = this.base
return pc
@ -813,11 +827,13 @@ export default defineComponent({
this.localeOpen = false
saveLocale(code)
try {
const row = this.active
const slot = this._slots && this._slots[this.activeKey]
const el = slot && slot.firstElementChild
if (el && el.setAttribute && row) {
el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, code)))
// EVERY mounted pane, not only the one on screen: a kept-alive pane showed the old language
// when the reader came back to it.
for (const row of this.rows) {
if (!row.mounted) continue
const slot = this._slots && this._slots[row.key]
const el = slot && slot.firstElementChild
if (el && el.setAttribute) el.setAttribute('profile-config', JSON.stringify(this.paneConfig(row, code)))
}
} catch { /* non-fatal: the switcher still persists + updates the UI */ }
},