doxa-maps/library/i18n/index.js
doxa-maps-88655c70 09d03c60d2 A shared ?lang=ar link now translates the region names too, not just the chrome
The prayer API localises its labels from its own lang parameter, so the region
and country NAMES follow whatever languageCode resolves to. That computed read
props and profile-config only — never the URL. So a DIRECT LINK, which is the URL
a person actually sends someone, went through detectLocale() for the chrome and
then asked the API in English: Arabic headings around English region names.

MEASURED, and only visible because the two channels were compared rather than
each checked alone: region-dashboards rendered 597 Arabic chrome characters
through profile-config against 309 through ?lang=ar, and regions-mfe 2709 against
748. The host-locale gate passes both, because it asks host >= ref/2 and cannot
see an asymmetry in that direction. Diffing the two renders element by element
named the whole gap exactly: the seven region labels in the chooser legend
(.rd-regionkey__label) and the seven row links in the table (.rd-row-link).

The reader is EXTRACTED, not copied. library/i18n/index.js already parsed
?lang= / ?locale= inside detectLocale; it is now exported as localeFromUrl() and
used in both places, so the data call and the chrome can never disagree about
what the URL asked for. Re-implementing those two lines in the plugin would have
been a second copy of one fact, which is how the asset-path bug and the
PosterWall fallback table both went wrong.

After: ?lang=ar renders 30 Arabic elements against profile-config's 30 on
region-dashboards, and 42 against 42 on regions-mfe — 0 strings left English on
the shared-link path. All 23 bundles rebuilt because the library file reaches
them; composite freshness PASS, check:sri PASS.
2026-09-25 16:26:31 +00:00

245 lines
13 KiB
JavaScript

/**
* i18n/index.js — Vue I18n setup for doxa-map-mfe
*
* One i18n instance per <doxa-map> element (via entry.js configureApp).
* All 11 locale catalogs are inlined in the IIFE bundle (Vite can't code-split
* IIFE output anyway; strings-only catalog size is small).
*
* LAZY-LOAD EXPERIMENT (2026-04-26): converted to `import.meta.glob({ eager:false })`
* with `setLocale()` helper + eager `en` fallback. Result: bundle GREW by ~22 KB
* raw / ~5 KB gz because Vite's `inlineDynamicImports: true` (required for IIFE)
* inlines every dynamic chunk back into the IIFE AND adds per-import wrapper
* closures + a runtime resolver. Reverted to eager glob.
*
* If we ever want a real win here, the path is:
* - Ship `en` only in the IIFE.
* - Host other locales as standalone JSON at a known URL (e.g. /widgets/research-map/locales/<lc>.json).
* - Fetch + setLocaleMessage() on demand (no Vite dynamic-import involved).
* That requires Nuxt-side public asset placement and a CORS-safe fetch path,
* so it's a deploy-pipeline change, not a build-config tweak.
*
* Locales are organized as folder-per-locale with one JSON file per top-level
* area. All 11 locales ship 10 of the 11 areas (aria, buttons, detail, legend,
* messages, options, popup, prayerLaps, search, tabs). ONE area is still partial:
* regions.json exists for ar/de/en/es/fr/hi/pt/ru, so it/ro/zh fall back to
* English for regions.* — 54 keys of full sentences each, the last i18n debt in
* the app. A missing area file is fine: those namespaces resolve through
* fallbackLocale: 'en'.
*
* ⚠ THAT FALLBACK IS WHY THIS COMMENT GOES STALE SILENTLY, so trust the gates over
* these sentences: check:i18n-parity counts the missing keys per locale against a
* ratchet, and check:tabs-localized renders tabs.* and asserts the text actually
* CHANGES with the locale. Key-parity alone cannot tell a shipped translation from
* a dead one — tabs.json was fully translated in 7 locales while its only consumer
* reached it through a dynamic key no grep could see.
* A Vite eager glob loads all area files at build time and composes them into
* the per-locale message object.
*
* Locale detection priority:
* 1. ?lang= / ?locale= on the URL (a reader or a link can choose)
* 2. document.documentElement.lang (set by Polylang on WordPress)
* 3. 'en' fallback
*
* Missing-key behavior: falls back to English. Missing-locale: whole map renders in English.
*
* See documentation/feedback.md → Section A for the design rationale.
*/
import { watch } from 'vue'
import { createI18n } from 'vue-i18n'
// Eager glob — all locale subfolders inlined at build time
const rawModules = import.meta.glob('./locales/*/*.json', { eager: true, import: 'default' })
// rawModules keys look like './locales/ar/legend.json', './locales/ar/detail.json'
const messages = {}
for (const [path, subtree] of Object.entries(rawModules)) {
const match = path.match(/\.\/locales\/([^/]+)\/([^/]+)\.json$/)
if (!match) continue
const [, locale, area] = match
messages[locale] ??= {}
messages[locale][area] = subtree
}
/**
* The English catalogue, exported from the ONE module that already owns it.
*
* Four gospel profiles used to `import enGospel from '@map/i18n/locales/en/gospel.json'` to
* get a real English fallback for a host that never installed i18n. That file is ALSO pulled
* in by the eager glob above, so the bundle ended up with two instances of the same module
* and the IIFE threw "Identifier 'Ge' has already been declared" on evaluation — a hard
* error that killed every gospel leaf while the SRI hash still matched and the page still
* returned 200. Importing it from here instead means there is exactly one.
*/
export const EN_MESSAGES = messages.en || {}
export const SUPPORTED_LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
export const RTL_LOCALES = new Set(['ar'])
/**
* The locale a ?lang= / ?locale= link is asking for, or '' if the URL names none.
*
* ★ EXPORTED BECAUSE A SECOND CONSUMER NEEDS THE SAME FACT. detectLocale() uses this for the UI
* chrome, and regions-dashboard needs it for the API call that fetches region and country NAMES:
* the prayer API localises its labels from its own lang parameter, so if the chrome reads the URL
* and the data call does not, a shared ?lang=ar link renders Arabic chrome around English region
* names. MEASURED before this: region-dashboards rendered 597 Arabic chrome characters through
* profile-config and only 309 through ?lang=ar, the gap being the seven region names in the
* chooser legend and the seven in the table.
*
* Both spellings are accepted because both are in use in this codebase (`lang` in profile configs,
* `locale` in the i18n layer) and guessing wrong is a silent no-op. Kept here, as one copy, rather
* than re-read in the plugin: two copies of one fact drift, which is how the asset-path bug and
* the PosterWall fallback table both went wrong.
*/
export function localeFromUrl() {
try {
const q = new URLSearchParams(window.location.search)
return (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
} catch { return '' } // no window (SSR/tests)
}
export function detectLocale() {
// ★ URL FIRST — otherwise the translations are UNREACHABLE in a dropped tree.
// The host page is the intended source (Polylang sets <html lang> on WordPress),
// but EVERY generated leaf page in this repo is hardcoded lang="en": measured
// 2026-09-24, 20 of 20. So on the built/static tree — the jamstack case, which is
// the whole point of these bundles — a reader had no way to reach any of the ten
// non-English locales, and the prayer packet, the card deck and every label
// rendered in English no matter how complete the translation was.
//
// A query parameter is the smallest thing that makes them reachable: it needs no
// per-language page, it is linkable and shareable, and it leaves the host-page
// path intact for WordPress. Both spellings are accepted because both are in use
// across this codebase (`lang` in profile configs, `locale` in the i18n layer),
// and guessing wrong is a silent no-op.
const fromUrl = localeFromUrl()
if (SUPPORTED_LOCALES.includes(fromUrl)) return fromUrl
const raw = document?.documentElement?.lang || ''
const code = raw.split('-')[0].toLowerCase()
return SUPPORTED_LOCALES.includes(code) ? code : 'en'
}
/**
* Build a fresh i18n instance. Called once per <doxa-map> element.
*/
export function createAppI18n() {
const locale = detectLocale()
// ⚠ REFLECT AN RTL LOCALE ON THE DOCUMENT. Only one component reads RTL_LOCALES
// today (regions' UupgsList), so choosing Arabic would otherwise give Arabic TEXT
// inside a left-to-right layout everywhere else — readable glyphs in the wrong
// place, which reads as a styling quirk rather than the bug it is. The prayer
// packet sets its own dir internally; the app chrome had nothing.
//
// Only ever SET it, never clear it: a host page that already declares dir (a
// WordPress RTL theme) owns that decision, and stomping it to "ltr" for an
// English map embedded in an Arabic page would break the page around us.
try {
if (RTL_LOCALES.has(locale) && document?.documentElement) {
document.documentElement.dir = 'rtl'
}
} catch { /* no document (SSR/tests) — nothing to reflect */ }
const i18n = createI18n({
legacy: false,
globalInjection: true,
locale,
fallbackLocale: 'en',
messages,
missingWarn: false,
fallbackWarn: false,
})
// ★ INSTALLING i18n ALSO PROVIDES IT, so a bundle CANNOT forget. This is the durable half of a
// bug that is invisible by construction.
//
// THE DEFECT: ProfileLoader applies a host's `profile-config.locale` by driving the app's i18n
// directly — but it reaches it with `inject('appI18n', null)` (ProfileLoader.vue:168-169):
// const appI18n = inject('appI18n', null)
// watchEffect(() => { if (lang.value && appI18n) appI18n.global.locale.value = lang.value })
// The inject DEFAULT IS NULL. So a bundle whose entry does `app.use(createAppI18n())` without a
// matching `app.provide('appI18n', i18n)` turns that watchEffect into a SILENT no-op: no error,
// no warning, no translation. MEASURED by regions on my-upg-100-list — profile-config.locale
// 'ar' produced ZERO Arabic strings, while the same harness switched regions-dashboard (which
// does provide it) perfectly. A host embedding the element simply cannot set its language.
//
// ⚠ WHY HERE AND NOT IN SIX index.js FILES. The fix is one line per bundle, which is exactly
// why it decays: it is one fact stored in six places, and the seventh bundle written next month
// re-introduces it with nothing to catch it. An i18n instance IS a Vue plugin, so wrapping its
// own install() makes the provide a consequence of installing rather than a step beside it.
// Bundles that already call app.provide('appI18n', i18n) with this same instance are
// unaffected — providing the identical value twice is a no-op.
// ★ AND PUBLISH THE LOCALE WHERE THE PLUGIN-FREE READER CAN SEE IT. Same shape of argument as
// the provide above, one layer down: tStatic (below) resolves the locale from the URL and the
// document, which are the only two things a STATIC page can say — so a locale set at RUNTIME, by
// a host's profile-config or by a language picker, moved every t() string and left every tStatic
// string in English. MEASURED on research-map-plus-grid: the map's six tabs came back Arabic
// while the grid's four dropdown labels stayed English, on the same screen. Half a translation
// reads as "some strings were never translated", and sends the search to the catalogues, where
// every one of those strings is already present and correct.
//
// Watching the instance's own locale ref is the single choke point: ProfileLoader, the SPA's
// picker and the studio's picker all move THIS ref, so none of them has to remember to also tell
// the static reader.
watch(i18n.global.locale, (l) => setRuntimeLocale(l), { immediate: true })
const install = i18n.install.bind(i18n)
i18n.install = (app, ...rest) => {
const out = install(app, ...rest)
app.provide('appI18n', i18n)
return out
}
return i18n
}
export default createAppI18n
/**
* Read a catalogue string WITHOUT the vue-i18n plugin.
*
* WHY THIS EXISTS. Some components are mounted as their OWN custom element by a bundle entry that
* calls defineCustomElement(Component) and installs nothing — people-groups-grid is one. useI18n()
* inside such a component THROWS, and a thrown setup() removes the subtree silently (this repo has
* already paid for that once: three buttons vanished behind ignorable console noise). So a shared
* component that must work in BOTH mounts needs a plugin-free reader, which is all this is.
*
* Falls back locale -> en -> undefined, so a caller can keep its own literal as the last resort and
* a missing translation degrades to English rather than to a key.
*
* @param {string} path dotted, e.g. 'grid.hide_filters'
* @param {string} [locale] defaults to the detected one (?lang= / host / browser)
*/
// ★ THE LOCALE THIS PAGE IS BEING READ IN, KEPT ON THE WINDOW AND NOT IN THIS MODULE.
// MEASURED, and the measurement is the whole reason for the odd-looking global: a composite loads
// SEVERAL bundles — research-map-plus-grid loads its own, doxa-research-map's and
// people-groups-grid's — and each bundle carries its own copy of this module. A module-level
// variable set inside the map's copy is invisible to the grid's copy, so the first version of this
// fix translated the map and left the grid in English, which is exactly the defect it was meant to
// remove. Module state is per-BUNDLE; a locale is per-PAGE. The shared map token crosses bundles
// the same way for the same reason (library/composables/useSharedMapToken.js).
//
// Guarded because there is no window in SSR or a unit test, and because a page can be served where
// touching a global throws; a locale that cannot be published simply leaves detectLocale in charge.
const PAGE_LOCALE_KEY = '__DOXA_PAGE_LOCALE'
export function setRuntimeLocale(code) {
const c = String(code || '').split('-')[0].toLowerCase()
if (!SUPPORTED_LOCALES.includes(c)) return
try { if (typeof window !== 'undefined') window[PAGE_LOCALE_KEY] = c } catch { /* no window */ }
}
export function getRuntimeLocale() {
try { return (typeof window !== 'undefined' && window[PAGE_LOCALE_KEY]) || '' } catch { return '' }
}
export function tStatic(path, locale) {
const tryTree = (loc) => {
let node = messages[loc]
for (const part of String(path).split('.')) {
if (!node || typeof node !== 'object') return undefined
node = node[part]
}
return typeof node === 'string' ? node : undefined
}
// Explicit argument first, then the locale a host set at runtime, then what the page itself says.
const want = locale || getRuntimeLocale() || detectLocale()
return tryTree(want) ?? tryTree('en')
}