From 5c54750d588aefe25ee6ca3a1335a810d9395c46 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 05:23:57 +0000 Subject: [PATCH] Fetch the catalogue for a host-supplied locale, and hold RTL text while it lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locale can arrive two ways. ?lang and are read at script evaluation, and the preload in i18n/index.js covers them. profile-config — the channel a host page uses — reaches ProfileLoader's watchEffect and nowhere else, which is the channel's whole purpose; the comment there already records that an earlier document-level fix never saw it. So with the build shipping one locale, a host-configured embed set its locale, mirrored its direction, and then rendered English with no error anywhere. One line beside the setRuntimeLocale that marks that path. The RTL hold is the second half. When the catalogue is fetched rather than inlined, nine locales get a re-render — the words change, nothing moves — and Arabic gets a layout flip: English left to right, then the whole chrome mirrors. So the text waits and the map does not. Not a v-if on the profile: is the entire plugin, map included, and gating it would hold the one thing that must never wait. A data attribute on the existing root plus a scoped rule over named chrome classes leaves every component mounted, re-enters nothing, and lets the map paint on its own schedule. The classes are named rather than matched by a blanket descendant rule because two of the text-bearing components also carry map objects. visibility rather than display, so the chrome keeps its box and nothing reflows when the text appears — a reflow at swap time is the same defect as the flip, only quieter. The gate asserts the seam. "ProfileLoader mentions loadLocale" would pass on an import nothing calls, so the watchEffect's own body is read by brace depth and the call has to sit inside it. It also refuses a hold that names anything map-shaped, a hold applied to the whole subtree, and a fetch that could run without a pin. Poisoned both halves: removing the call and pointing the hold at a map container each fail with the message that names the defect. --- internal/check-host-locale-fetch.mjs | 88 ++++++++++++++++++++++++++++ library/ProfileLoader.vue | 61 ++++++++++++++++++- package.json | 4 +- 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 internal/check-host-locale-fetch.mjs diff --git a/internal/check-host-locale-fetch.mjs b/internal/check-host-locale-fetch.mjs new file mode 100644 index 000000000..db7626eec --- /dev/null +++ b/internal/check-host-locale-fetch.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * check-host-locale-fetch — a locale supplied through profile-config must fetch its catalogue. + * + * ★ WHAT THIS CAUGHT. When the build ships one locale (DX_SHIP_ONE_LOCALE), the preload in + * library/i18n/index.js fires once at script evaluation from detectLocale(), which reads only the + * URL and . A locale arriving through profile-config — the HOST channel, the one a + * partner page uses — reaches ProfileLoader's watchEffect and nowhere else. Without a loadLocale() + * call there, that embed sets its locale, mirrors its direction, and then renders English, with no + * error anywhere: the silent-fallback shape this gate family exists to refuse. + * + * ⚠ ASSERTED ON THE SEAM, NOT ON A STRING. "ProfileLoader mentions loadLocale" would pass on an + * import that nothing calls, and on a call in a branch that never runs. So the watchEffect's own + * body is read, and the call has to sit beside the setRuntimeLocale() that marks the host-channel + * path — the two belong together because they answer the same question for different consumers. + * + * Reads real source. Headless, no browser, no network. Exit 0 pass · 1 fail. + */ +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const read = (p) => readFileSync(resolve(ROOT, p), 'utf8') + +const fails = [] +const ok = (cond, msg) => { if (!cond) fails.push(msg) } + +const PL = read('library/ProfileLoader.vue') +const I18N = read('library/i18n/index.js') + +// ── 1. THE HOST CHANNEL EXISTS AND STILL LOOKS THE WAY THIS GATE ASSUMES ─────────────────────── +ok(/config\.value\?\.locale \|\| config\.value\?\.lang/.test(PL), + 'ProfileLoader no longer reads the locale from profile-config — this gate is judging a path that moved') + +// ── 2. THE WATCHEFFECT THAT APPLIES IT ALSO FETCHES IT ───────────────────────────────────────── +// The body is taken by brace depth from the watchEffect that calls setRuntimeLocale, so a call +// sitting elsewhere in the file cannot satisfy this. +const at = PL.indexOf('watchEffect(() => {\n if (!lang.value) return') +ok(at >= 0, 'the locale watchEffect was not found — it was renamed or restructured') +if (at >= 0) { + const open = PL.indexOf('{', PL.indexOf('=>', at)) + let depth = 0, end = open + for (let i = open; i < PL.length; i++) { + if (PL[i] === '{') depth++ + else if (PL[i] === '}' && --depth === 0) { end = i; break } + } + const body = PL.slice(at, end + 1) + ok(/setRuntimeLocale\(lang\.value\)/.test(body), + 'the watchEffect no longer publishes the locale — this gate is anchored to the wrong effect') + ok(/loadLocale\([^)]*lang\.value\)/.test(body), + 'the locale watchEffect does not call loadLocale(), so a locale set through profile-config never ' + + 'fetches its catalogue: the embed mirrors its direction and renders English with no error') +} +ok(/import \{[^}]*\bloadLocale\b[^}]*\} from '\.\/i18n\/index\.js'/.test(PL), + 'ProfileLoader does not import loadLocale from the i18n module') + +// ── 3. THE FETCH IS PINNED, AND UNREACHABLE WITHOUT A PIN ────────────────────────────────────── +// Keying on the hash map is what keeps the flag-OFF build off the network: a bundle with no areas +// inlines nothing, so a messages[] test would let the preload request a file that was never emitted. +ok(/if \(!LOCALE_SRI\[c\]\) return/.test(I18N), + 'loadLocale no longer refuses to fetch without a pin — the flag-off build would hit the network, ' + + 'and any fetch that did happen would be unpinned') +ok(/fetch\(url, \{ integrity: LOCALE_SRI\[c\] \}\)/.test(I18N), + 'the catalogue fetch does not pass its integrity hash unconditionally') + +// ── 4. THE RTL HOLD NEVER REACHES THE MAP ────────────────────────────────────────────────────── +// A blanket rule on descendants would hide the map subtree, which is the outcome the hold exists to +// avoid: the map must paint while only text waits. +const held = [...PL.matchAll(/\[data-locale-pending\] :deep\(\.([a-z0-9-]+)\)/g)].map((m) => m[1]) +ok(held.length > 0, 'no chrome classes are held while an RTL catalogue is in flight — the hold is not wired') +ok(!/\[data-locale-pending\]\s*\{/.test(PL) && !/\[data-locale-pending\] :deep\(\*\)/.test(PL), + 'the hold is applied to the whole subtree rather than to named chrome, so it would hide the map') +for (const cls of held) { + ok(!/^(map|mapbox|maplibre|canvas)/.test(cls), + `the hold names .${cls}, which looks like a map container — only text chrome may ever wait`) +} +ok(/\[data-locale-pending\][\s\S]{0,900}visibility: hidden/.test(PL), + 'the hold uses something other than visibility — display:none would reflow the chrome back in when ' + + 'the text lands, which is the same visual defect as the flip') + +if (fails.length) { + console.error('HOST-LOCALE-FETCH: FAIL') + for (const f of fails) console.error(` ✗ ${f}`) + process.exit(1) +} +console.log(`HOST-LOCALE-FETCH: PASS — a profile-config locale fetches its catalogue, the fetch is ` + + `unreachable without a pin, and the RTL hold covers ${held.length} named chrome class(es) and no map.`) diff --git a/library/ProfileLoader.vue b/library/ProfileLoader.vue index 56a3364ad..578e72a4e 100644 --- a/library/ProfileLoader.vue +++ b/library/ProfileLoader.vue @@ -14,10 +14,10 @@ * never interfere with each other. */ -import { computed, provide, inject, watchEffect, watch } from 'vue' +import { computed, provide, inject, watchEffect, watch, ref } from 'vue' import { useSharedMapToken, publishMapToken, fetchMapToken } from './composables/useSharedMapToken.js' import { retryableAsyncComponent } from './composables/retryableAsyncComponent.js' -import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl } from './i18n/index.js' +import { SUPPORTED_LOCALES, RTL_LOCALES, setRuntimeLocale, localeFromUrl, loadLocale, localeChromeReady } from './i18n/index.js' // QA corner: COMPILE-TIME gated (__DOXA_QA__, vite define). In public builds // the flag is false → this whole branch (and the widget's code, including its // platform references) is eliminated from the bundle. Platform builds @@ -181,6 +181,14 @@ watchEffect(() => { // t(); tStatic reads the URL and the document and would still say English. A bundle that installs // no i18n at all reaches this line and nothing else, which is the case it exists for. setRuntimeLocale(lang.value) + // ★ THE HOST CHANNEL NEEDS ITS CATALOGUE FETCHED. When the build ships one locale, the preload in + // i18n/index.js fires once at script evaluation from detectLocale() — which reads only the URL and + // . A locale arriving through profile-config gets here and nowhere else (that is the + // whole point of the channel, and the comment below records that an earlier document-level fix + // never saw it), so without this line a host-configured embed sets the locale, mirrors its + // direction, and then renders English with no error. No-op when the locale is inlined, already + // fetched, or unpinned — see loadLocale. + loadLocale(appI18n, lang.value).catch(() => { /* stays on the English fallback */ }) }) // Effective locale for non-i18n descendants (the geocoder reads useI18n directly). const effectiveLocale = computed(() => lang.value || appI18n?.global?.locale?.value || 'en') @@ -207,6 +215,30 @@ const effectiveLocale = computed(() => lang.value || appI18n?.global?.locale?.va // the failure above, so it never touches the document at all. const dirAttr = computed(() => (RTL_LOCALES.has(effectiveLocale.value) ? 'rtl' : 'ltr')) +// ── HOLD THE TEXT FOR AN RTL LOCALE, NEVER THE MAP ────────────────────────── +// When the catalogue is fetched rather than inlined, a non-English reader sees English until it +// lands. For nine of the ten that is a re-render — the words change and nothing moves. For Arabic it +// is a LAYOUT FLIP: English left-to-right, then the whole chrome mirrors. So the text waits and the +// map does not. +// +// ⚠ NOT A v-if ON THE PROFILE. is the ENTIRE plugin, map +// included, so gating it would hold the one thing that must never wait. This is an ATTRIBUTE on the +// existing root and a scoped rule that hides named chrome classes — every component stays mounted, +// nothing is re-entered, and the map paints on its own schedule. +// +// ⚠ visibility, NOT display: the chrome keeps its box, so nothing reflows when the text appears. A +// reflow at swap time is the same defect as the flip, only quieter. +const localePending = ref(false) +watchEffect((onCleanup) => { + const loc = effectiveLocale.value + if (!RTL_LOCALES.has(loc)) { localePending.value = false; return } + let live = true + onCleanup(() => { live = false }) + localePending.value = true + // Always resolves — on the catalogue, or on the timeout inside localeChromeReady. + localeChromeReady(loc).finally(() => { if (live) localePending.value = false }) +}) + // ─── Instance ID ───────────────────────────────────────────────────────────── // Optional in profile-config: { "instanceId": "my-map" } // If not provided, a random ID is generated (stable for the element's lifetime). @@ -282,7 +314,7 @@ provide('dataStore', dataStore)