doxa-maps/library/i18n/index.js
doxa-maps-88655c70 b4a0387495 Translate the chrome that was English in all eleven locales
Measured under ?lang=ru on both composites: every Latin-script string left on the
page was ours. The map above was fully translated; underneath it the grid, the
map's own side menu and filter panel, and the whole research dashboard were
English in Arabic, Chinese, Hindi, Russian and everything else.

Three surfaces, three reasons, none of them a missing translation:

  the grid          read labels from a host-supplied `t` object that nothing ever
                    filled, with an English literal at each call site as fallback
  side menu +
  filter panel      plain string literals
  dashboard         labels declared as literals in dashboard.config.js — facet
                    rail, chart titles, KPI strip, table headers, segmented
                    control, search placeholder

New catalogues for all 11 locales (grid, controls, dashboard), REUSING the
catalogue's existing wording wherever the word already existed — Регион, Блок
родства, Население, Вовлечён, Графики — so two panels never name one thing two
ways, and nothing is coined that the project already has a word for.

Read through a new plugin-free tStatic() rather than useI18n, because these
components are also mounted by entries that install no i18n plugin. An unguarded
useI18n() throws and a thrown setup() removes the subtree with no build error —
the defect that had just blanked people-groups-grid everywhere (6603dc17).

★ THE CONFIG LABELS ARE GETTERS, AND A SPREAD WOULD HAVE LOOKED IDENTICAL.
dashboard.config.js is imported once at load; the locale is only known later. My
first version spread a getter object, and a spread COPIES THE RESOLVED VALUE, not
the accessor — every label would have frozen at whichever locale loaded first,
and it would have looked perfect in English. `get label() {…}` resolves on each
read.

MEASURED after, served built tree, identity checked first: Big Picture ru/ar and
Simple Research ru/zh have ZERO Latin-script chrome strings. What remains is
Mapbox's licence attribution (never translate), country and people-group names
(data), and 'UUPGs' — which every locale file deliberately keeps as the acronym.

Three gates went red on the way, two of them mine and worth recording:
  check:locale-reachable  slices this i18n module between two markers and EVALS
                          the slice; I had put tStatic between them, so the gate
                          hit an `export`. Moved to the end of the file.
  check:library-index     my import+helper above a component's docblock became
                          that component's DERIVED description. Moved below it.
  check:kpi-unit          its parser assumed single-brace entries, so getters made
                          it find ZERO pct KPIs — and it failed rather than
                          printing a green PASS about nothing, which is the gate
                          working. Parse now understands getters AND checks the
                          translated labels, so a '%' reintroduced in any of the
                          11 catalogues fails by name. Both controls fired.
2026-09-25 06:10:27 +00:00

151 lines
7 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 { 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
}
export const SUPPORTED_LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
export const RTL_LOCALES = new Set(['ar'])
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 = (() => {
try {
const q = new URLSearchParams(window.location.search)
return (q.get('lang') || q.get('locale') || '').split('-')[0].toLowerCase()
} catch { return '' } // no window (SSR/tests) — fall through
})()
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 */ }
return createI18n({
legacy: false,
globalInjection: true,
locale,
fallbackLocale: 'en',
messages,
missingWarn: false,
fallbackWarn: false,
})
}
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)
*/
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
}
const want = locale || detectLocale()
return tryTree(want) ?? tryTree('en')
}