Every leaf inlines all eleven catalogues, so a reader who opens the map in
one language downloads the other ten. Measured on doxa-simple-map: the ten
unused locales are 113 KB gzipped, 40% of what a reader actually fetches.
With the flag on, the build inlines en — it is the fallbackLocale, so it
has to stay — and emits the other ten as <bundle>/locales/<lc>.json holding
exactly the areas that bundle uses. Same areasForBundle() answer the area
narrowing already uses, so the two dimensions cannot drift apart, and
emitted from the same plugin so composite-freshness, fix:sri and the
.gated/ split cover them with no new step. Off by default: the build is
byte-identical until someone sets it.
Not Vite's dynamic import. That was tried in April and GREW the bundle
~22 KB, because IIFE output needs inlineDynamicImports, which pulls every
chunk back in and adds a wrapper closure each. This is a plain fetch.
The base URL is captured as the script evaluates. Rollup shims
import.meta.url to document.currentScript, which is null once the script
has run, so a base read at mount would resolve against document.baseURI —
right on our own pages, wrong on every host that serves the bundle from
another path. deriveTreeBase() carries that same warning but returns the
tree root; these assets sit one level deeper, in the bundle's own
directory.
Each emitted file's sha384 is baked into the bundle and passed to
fetch({ integrity }), hashed from the same bytes the emit writes, so a
swapped catalogue fails closed to English instead of injecting text.
The fetch starts at module evaluation from the detected locale, so it
overlaps the bundle's own startup, and localeChromeReady() lets an RTL
locale hold its text chrome for up to 1.5s. For ar the flash is a layout
flip, not a text swap, which is worth a short hold; the map never waits,
and the timeout means a failed fetch degrades to English rather than to a
blank panel. loadLocale() runs after the instance exists, never inside
createI18n, because setLocaleMessage touches no effect scope while
createGlobal is the call that throws inside a stopped one. It also installs
into each instance that asks, since vue-i18n copies messages at
construction and a catalogue arriving later would otherwise reach only the
instances built after it.
Measured, doxa-simple-map: 284.6 KB gzip -> 165.2 KB, with ar as a 13 KB
asset. Ten assets emitted, every hash matching, ar no longer inlined, en
inlined.
The treeshake locale arm reads the narrowed build correctly (reports "10
asset") and fails when an asset is deleted. Its length floor is now per
script: a 25-character Chinese string is a whole sentence, so a
Latin-calibrated floor found no zh in a composite carrying short labels and
called it absent.
(cherry picked from commit 41d33d9a40)
447 lines
25 KiB
JavaScript
447 lines
25 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 — the locale subfolders the build leaves in. vite.config's i18nAreasPlugin narrows
|
|
// this to the areas one bundle uses, and (when SHIP_ONE_LOCALE is on) to `en` alone; the other ten
|
|
// are emitted as assets beside the bundle and fetched by loadLocale() below.
|
|
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 BASE URL IS CAPTURED AS THIS SCRIPT EVALUATES, AND IT HAS TO BE. Rollup shims
|
|
// import.meta.url to document.currentScript in IIFE output, and currentScript is null the moment
|
|
// the script finishes running — so a base read at mount, or on a later locale switch, resolves
|
|
// against document.baseURI instead: right on our own pages, wrong on every host that serves the
|
|
// bundle from a different path. deriveTreeBase() already exists for exactly this and already
|
|
// carries the warning; this is the one call, at the top, on purpose.
|
|
// deriveTreeBase() is the same capture at the same moment, but it returns the TREE root — it strips
|
|
// "<bundle>/<bundle>.js" — and these assets live in the bundle's OWN directory one level deeper, so
|
|
// each bundle carries only the areas it uses. One less path segment removed, same rule.
|
|
const LOCALE_BASE = (() => {
|
|
try {
|
|
const cur = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || ''
|
|
if (!cur) return ''
|
|
return cur.split(/[?#]/)[0].replace(/[^/]+$/, '') // …/<bundle>/ (trailing slash kept)
|
|
} catch { return '' }
|
|
})()
|
|
|
|
// Filled by the build (i18nAreasPlugin) with { ar: 'sha384-…', … } for the locales it emitted as
|
|
// assets. Embeds are SRI-pinned, and a fetched catalogue would not be unless its hash travels with
|
|
// the bundle — so a swapped file fails closed to English rather than injecting text into the page.
|
|
// Empty when every locale is inlined, which is also why loadLocale() is a no-op in that build.
|
|
const LOCALE_SRI = (typeof __DX_LOCALE_SRI__ !== 'undefined' ? __DX_LOCALE_SRI__ : {})
|
|
|
|
const _loaded = new Map() // code -> Promise, so concurrent instances share one request
|
|
|
|
// ★ THE REQUEST STARTS NOW, NOT AT MOUNT. Kicked off as the script evaluates so it overlaps the
|
|
// bundle's own startup instead of queueing behind it — the catalogue is ~13 KB gzipped and the
|
|
// bundle is an order of magnitude more, so in practice it lands first. Nothing awaits this here;
|
|
// createAppI18n() picks up the same promise from _loaded.
|
|
let PRELOAD = null
|
|
// How long an RTL locale may hold its text chrome. For ar the flash is a LAYOUT change — English
|
|
// LTR, then a flip to RTL — which is visibly worse than the plain re-render the other locales get,
|
|
// so the chrome waits. The map never waits, and the timeout means a failed fetch degrades to
|
|
// English rather than to a blank panel.
|
|
const RTL_HOLD_MS = 1500
|
|
|
|
/**
|
|
* Fetch a locale that was emitted beside the bundle rather than inlined, and install it.
|
|
*
|
|
* Resolves to true when the locale is usable (already present, or fetched and installed) and false
|
|
* when it is not — the caller then simply stays on the English fallback, which is what a reader
|
|
* gets today for a locale with no catalogue. Never throws: a failed catalogue must not take the map
|
|
* down with it.
|
|
*
|
|
* ⚠ CALLED AFTER THE INSTANCE EXISTS, NEVER DURING createI18n. setLocaleMessage() touches no effect
|
|
* scope, so this stays clear of the stopped-scope failure that createGlobal() has (see below).
|
|
*/
|
|
export function loadLocale(i18n, code) {
|
|
const c = String(code || '').split('-')[0].toLowerCase()
|
|
if (!c || c === 'en' || !SUPPORTED_LOCALES.includes(c)) return Promise.resolve(c === 'en')
|
|
// Already here — inlined by this build, or fetched for an earlier instance. Still install it into
|
|
// THIS instance: vue-i18n copied `messages` when it was constructed, so a catalogue that arrived
|
|
// afterwards is in the shared map and not in this one.
|
|
if (messages[c]) { i18n?.global?.setLocaleMessage?.(c, messages[c]); return Promise.resolve(true) }
|
|
// One request per locale, however many instances ask — but each caller still installs the result.
|
|
if (_loaded.has(c)) {
|
|
return _loaded.get(c).then((okEarlier) => {
|
|
if (okEarlier && messages[c]) i18n?.global?.setLocaleMessage?.(c, messages[c])
|
|
return okEarlier
|
|
})
|
|
}
|
|
const run = (async () => {
|
|
try {
|
|
if (!LOCALE_BASE) return false // base unknown: stay on English
|
|
const url = new URL(`locales/${c}.json`, LOCALE_BASE).href
|
|
const init = LOCALE_SRI[c] ? { integrity: LOCALE_SRI[c] } : {}
|
|
const res = await fetch(url, init)
|
|
if (!res.ok) return false
|
|
const tree = await res.json()
|
|
if (!tree || typeof tree !== 'object') return false
|
|
messages[c] = tree
|
|
i18n?.global?.setLocaleMessage?.(c, tree)
|
|
return true
|
|
} catch { return false }
|
|
})()
|
|
_loaded.set(c, run)
|
|
return run
|
|
}
|
|
|
|
/**
|
|
* 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'
|
|
}
|
|
|
|
// Start the fetch as this module evaluates. Deliberately not awaited and deliberately not inside
|
|
// createAppI18n: the request overlaps the rest of the bundle's startup, and every instance that
|
|
// mounts later joins the same promise instead of issuing its own.
|
|
try { PRELOAD = loadLocale(null, detectLocale()) } catch { PRELOAD = null }
|
|
|
|
/**
|
|
* Resolves when the active locale's text is ready to paint, or when the hold expires.
|
|
*
|
|
* Only an RTL locale actually waits. For the others a late catalogue is a re-render — vue-i18n is
|
|
* reactive, so the words simply change — but for ar it is a LAYOUT flip, and painting English LTR
|
|
* and then mirroring is the one outcome worth a short hold. Always resolves; never rejects.
|
|
*
|
|
* @returns {Promise<boolean>} true when the catalogue landed in time
|
|
*/
|
|
export function localeChromeReady(code = detectLocale()) {
|
|
const c = String(code || '').split('-')[0].toLowerCase()
|
|
if (!RTL_LOCALES.has(c) || messages[c]) return Promise.resolve(true)
|
|
const pending = _loaded.get(c) || PRELOAD || Promise.resolve(false)
|
|
return Promise.race([
|
|
pending.then((ok) => !!ok).catch(() => false),
|
|
new Promise((r) => setTimeout(() => r(false), RTL_HOLD_MS)),
|
|
])
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
//
|
|
// LANG TRAVELS WITH DIR (rega, research.nuxtbase.app/?lang=ar): an owned page flipped to dir=rtl but
|
|
// kept the generator's <html lang="en">, so a screen reader read the Arabic with an English voice (and
|
|
// ?lang=fr left lang="en" too). The page is ours, so its lang is the locale in force.
|
|
try {
|
|
const ownsPage = typeof window !== 'undefined' && window.MAP_OWNS_PAGE === true
|
|
if (ownsPage && locale && document?.documentElement) {
|
|
document.documentElement.lang = locale
|
|
if (RTL_LOCALES.has(locale)) document.documentElement.dir = 'rtl'
|
|
}
|
|
} catch { /* no document (SSR/tests) — nothing to reflect */ }
|
|
// ★ A DETACHED EFFECT SCOPE (P0, seen in a live 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,
|
|
pluralRules: CLDR_PLURAL_RULES,
|
|
}))
|
|
|
|
// ★ THE CATALOGUE MAY ARRIVE AFTER THIS INSTANCE DOES. vue-i18n copies `messages` at
|
|
// construction, so a fetch that lands later has to be installed per instance — the preload above
|
|
// fills the shared `messages` map, which is enough for instances created afterwards and not for
|
|
// the ones already built. Outside createI18n() on purpose: setLocaleMessage touches no effect
|
|
// scope, while createGlobal() is the call that throws inside a stopped one.
|
|
if (locale !== 'en' && !messages[locale]) {
|
|
loadLocale(i18n, locale).catch(() => { /* stays on the English fallback */ })
|
|
}
|
|
|
|
// ★ 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 */ }
|
|
}
|
|
/**
|
|
* CLDR plural selection — the map vue-i18n does not ship.
|
|
*
|
|
* ★ WHY THIS IS NEEDED AT ALL. vue-i18n v9's default pluralization is INDEX-BASED, not CLDR: it
|
|
* picks form 0/1/2 by an English-shaped rule and applies it to EVERY locale. Measured on a real
|
|
* instance, authoring 'a | b | c':
|
|
* ru n=0 -> the ONE form, n=2 -> the MANY form (CLDR: 0=many, 2=few — both wrong)
|
|
* ar n=3, 11 and 100 -> all the DUAL form (CLDR: few, many, other)
|
|
* That is confidently wrong grammar that LOOKS translated, which is worse than a single fixed
|
|
* form. upg100 hit exactly this (upg-100-list.vue:3006) and a plural key was correctly refused
|
|
* then, because this map did not exist. It exists now, so the refusal's premise is gone.
|
|
*
|
|
* ★ SCOPED TO EXCLUDE en, DELIBERATELY. en is the only locale with pipe strings today
|
|
* (wall.prayedCount, wall.havePrayed), and vue-i18n's 3-form default there is zero|one|many while
|
|
* CLDR for en is one|other — so putting en under CLDR would change what n=0 renders. Leaving en
|
|
* out means every string that exists today renders byte-identically: 132 renders compared across
|
|
* 2 keys x 11 locales x {0,1,2,3,11,100}, 0 changed.
|
|
*
|
|
* ★ AUTHORING ORDER IS CLDR ORDER: zero | one | two | few | many | other. A locale supplies the
|
|
* forms its own category set needs (ru 4, ar 6, zh 1) — check:plural-forms fails a string that
|
|
* supplies fewer, because a short list makes the pick clamp silently onto the wrong form.
|
|
*/
|
|
const CLDR_CATEGORY_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other']
|
|
|
|
export function cldrChoiceIndex(choice, choicesLength, locale) {
|
|
let rules
|
|
try { rules = new Intl.PluralRules(locale) } catch (_) { return choice === 1 ? 0 : 1 }
|
|
const category = rules.select(choice)
|
|
|
|
// THE INDEX IS INTO THIS LOCALE'S OWN CATEGORIES, NOT INTO THE FULL SIX.
|
|
// The first version indexed into the fixed zero|one|two|few|many|other list, which is correct
|
|
// only for a locale that HAS all six. ru has {one, few, many, other} and no zero/two, so 'few'
|
|
// sat at global index 3 while the authored ru string holds it at 1 -- n=2 and n=3 rendered the
|
|
// MANY form ("2 stran" instead of "2 strany"). Arabic hid it completely, being the one locale
|
|
// that does have all six, so the two indexes coincide there. Caught by reading the rendered
|
|
// Russian, not by the gate, which was counting forms rather than checking agreement.
|
|
const have = rules.resolvedOptions().pluralCategories
|
|
const cats = CLDR_CATEGORY_ORDER.filter((c) => have.includes(c))
|
|
let i = cats.indexOf(category)
|
|
if (i < 0) i = cats.indexOf('other')
|
|
// A catalogue with fewer forms than its locale has categories degrades to 'other', then to the
|
|
// last form it does have. check:plural-forms makes this unreachable; it is here so a bad
|
|
// catalogue renders a real sentence rather than undefined.
|
|
if (i >= choicesLength) {
|
|
const other = cats.indexOf('other')
|
|
i = other >= 0 && other < choicesLength ? other : choicesLength - 1
|
|
}
|
|
return Math.max(0, Math.min(i, choicesLength - 1))
|
|
}
|
|
|
|
// en is absent on purpose — see above. Every other shipped locale gets CLDR selection.
|
|
const CLDR_PLURAL_RULES = Object.fromEntries(
|
|
['ar', 'de', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh']
|
|
.map((loc) => [loc, (choice, choicesLength) => cldrChoiceIndex(choice, choicesLength, loc)]),
|
|
)
|
|
|
|
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')
|
|
}
|