doxa-maps/library/i18n/index.js

486 lines
28 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
})
}
// ★ NO PIN, NO FETCH (studio). The hash map is written by the build ONLY for locales it actually
// emitted, so its presence is the one reliable statement that a file is there to fetch. Without
// this guard the flag-OFF build still reaches the network: a bundle with no areas inlines nothing,
// so messages[c] is empty and the preload requests locales/<lc>.json on every non-English page —
// a 404 per reader, and fetched with no integrity because there was no hash to pass. Keying on the
// pin makes the flag-off build inert and the flag-on build fail closed to English.
if (!LOCALE_SRI[c]) return Promise.resolve(!!messages[c])
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 res = await fetch(url, { integrity: LOCALE_SRI[c] })
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 (measured on a research page with ?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')
}
/**
* A list of items in the reader's language: formatList(['A', 'B', 'C']) is "A, B, and C" in English,
* "A وB وC" in Arabic, "A、B和C" in Chinese, "A, B und C" in German. `join(', ')` is wrong in every
* locale but English, and no catalogue key can fix it: the separator and the final conjunction ARE the
* grammar, and a key-built sentence like pins.sharedPoint still carried English commas inside Arabic prose
* (measured by gospel, 2026-09-29; 31 such joins across library and plugins). Intl.ListFormat knows every
* locale's list grammar, so this is only the locale resolution tStatic already uses: the explicit
* argument, then the host's runtime locale, then the page's own.
*
* type 'conjunction' (and, the default) · 'disjunction' (or) · 'unit' (no conjunction)
* style 'long' (default) · 'short' · 'narrow'
* Items are stringified. Where a browser has no Intl.ListFormat it falls back to ', ', today's behaviour.
* PICK THE TYPE FROM WHAT THE LIST MEANS. Several values on one filter facet are alternatives, so 'disjunction'
* (the chip bar: the bus matches with includes()). Things that all hold are a 'conjunction'. If a facet ever needs
* ALL its values, the type belongs in that facet's config, not fixed at the call site. Never 'unit' for names:
* in zh it joins them with no separator at all.
*/
const listFormats = new Map()
export function formatList(items, { type = 'conjunction', style = 'long', locale } = {}) {
const list = Array.from(items || [], (x) => String(x))
// The page's own locale only as the last resort, and never allowed to throw: outside a browser (a
// server render, a Node gate) there is no document to read it from.
const page = () => { try { return detectLocale() } catch { return '' } }
const want = locale || getRuntimeLocale() || page() || 'en'
const key = `${want}|${type}|${style}`
try {
if (!listFormats.has(key)) listFormats.set(key, new Intl.ListFormat(want, { type, style }))
return listFormats.get(key).format(list)
} catch {
return list.join(', ')
}
}