doxa-maps/library/i18n/index.js
Doxa Maps c08421f886
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source a1c8f3f6
2026-09-27 02:57:03 +00:00

271 lines
15 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, effectScope } 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.
// ★ ONLY WHERE WE OWN THE PAGE. This used to run whenever the locale resolved to an RTL
// language, which meant an EMBEDDED plugin on a partner's page flipped the PARTNER'S whole
// <html> — their headings, their nav, their footer — because a reader followed a ?lang=ar link.
// The comment below already worried about the host owning that decision, but it only guarded
// against CLEARING dir, never against setting it.
//
// A GENERATED LEAF owns its document and says so with window.MAP_OWNS_PAGE. Nothing else sets
// it, so the DEFAULT IS "not ours" — a partner page that declares nothing is never touched,
// which is the safe direction for a flag whose absence must mean restraint.
//
// The element's OWN direction is not this code's business and never was: ProfileLoader binds
// dir on .profile-loader from effectiveLocale (0f6d5049), so the plugin mirrors correctly
// whether or not it owns the document. This is only about the page around it.
try {
const ownsPage = typeof window !== 'undefined' && window.MAP_OWNS_PAGE === true
if (ownsPage && RTL_LOCALES.has(locale) && document?.documentElement) {
document.documentElement.dir = 'rtl'
}
} catch { /* no document (SSR/tests) — nothing to reflect */ }
// ★ A DETACHED EFFECT SCOPE (P0, the agent's console, 2026-09-27). vue-i18n's createGlobal() runs
// inside scope.run() and throws UNEXPECTED_ERROR ("SyntaxError: Unexpected error") when that returns
// undefined; Vue 3.5 creates a new EffectScope INACTIVE when the currently active scope is STOPPED.
// A custom element's connectedCallback -> configureApp -> here can run while a released tab's stopped
// scope is still current (Plugin Studio's gallery, every tab after the first). effectScope(true) never
// inherits the ambient scope, so this instance and its locale watcher are always created live.
const i18nScope = effectScope(true)
const i18n = i18nScope.run(() => 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.
i18nScope.run(() => 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)
// Several bundle entries still call app.provide('appI18n', i18n) themselves after app.use(i18n). The
// value is the same instance, but Vue warns "App already provides property with key appI18n" on every
// mount. A second provide of THIS instance under that key is made a no-op; anything else passes through.
const provide = app.provide.bind(app)
app.provide = (key, value) => (key === 'appI18n' && value === i18n ? app : provide(key, value))
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')
}