Wrote this after shipping a page that returned 200 for every asset, reported zero console errors, and was completely dead: a throw during setup dies inside the custom-element boundary. Zero errors is not the same as working, so this does not trust their absence — it looks for PRESENCE, on all five leaves, in five steps that each name their own failure: the tag is defined, the host is in the document, a shadowRoot is attached, it has children, it renders text. Its first real run went red on all five, and it was right. Every gospel leaf was dead with "Identifier 'Ge' has already been declared" — while the SRI hash still matched and every request still returned 200. My own doing: four profiles imported en/gospel.json directly for an English fallback, and library/i18n already pulls that exact file in through an eager glob, so the IIFE carried two instances of one module. The fix is one module: library/i18n now exports EN_MESSAGES and the profiles read it from there. The duplicate is gone. Measured after: all five leaves mount with 3,228 to 233,307 characters of rendered text, verify-gospel-map passes with 2,107 pins and the gap cutting to 370, and --poison confirms the check goes red on a page with no element at all. The lesson is the gate's whole reason to exist. I had rich assertions about pin counts and filters and none of them ran on data-tables, charts or the list, so a bundle-level failure could take out every leaf while the one gate I did run was pointed elsewhere. Cheap and universal beats rich and narrow for THIS question.
163 lines
7.6 KiB
JavaScript
163 lines
7.6 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
|
|
}
|
|
|
|
/**
|
|
* 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'])
|
|
|
|
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')
|
|
}
|