doxa-maps/library/composables/useOptionalT.js
Doxa Maps 61d97d8910 A host without i18n picks the same plural form an installed i18n would, in every language
useOptionalT's fallback (the path a bundle with no vue-i18n takes) picked a plural form by English's
rule for every locale, so "21 народ" and Arabic's six forms came out wrong there — regions caught it
before wiring their wall. The fallback now uses the SAME CLDR selection library/i18n/index.js installs
(cldrChoiceIndex, now exported), and English keeps vue-i18n's own rule, so both paths print the same
form. Checked against the real code: ru 1/3/5/21, ar 0/2/5/11, en 0/1/7.
2026-09-29 05:26:26 +00:00

129 lines
7.5 KiB
JavaScript

/**
* ★ NO `<area>.<key>`-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
* literal as a real catalogue key and reports it missing; studio had to reword mine twice because a
* file copy restored them. Describe keys in words here, never in the shape of one.
*
* useOptionalT(namespace) — one guarded translator, for the twenty places that each stored the same
* fact: HOW TO TRANSLATE WHEN THE HOST NEVER INSTALLED vue-i18n.
*
* `useI18n()` THROWS ("Need to install with `app.use` function") on a host without the plugin, and
* seven bundles here install none — big-picture, doxa-maps-spa, doxa-vision-card, golden-path-check,
* people-groups-grid, plugin-studio, stack-demo. An unguarded call in shared code is a blank subtree
* waiting for the first bare host: that is exactly how library FilterDropdown blanked
* people-groups-grid, a production plugin.
*
* ★ WHY THIS DOES NOT TAKE A FALLBACK CATALOGUE, though the curation spec specified
* `(namespace, fallbackCatalogue)`. Every existing copy imports its own English JSON
* (its own per-area English JSON) and hand-walks it after stripping the
* namespace prefix. That is REDUNDANT: `messages` in library/i18n/index.js is keyed
* `messages[locale][area]` where `area` is the JSON's own filename, so tStatic given a fully-qualified key already
* walks the same tree AND already falls back to English (`tryTree(want) ?? tryTree('en')`). The
* second import was a second copy of the same bytes.
*
* ★ AND IT IS NOT A BUNDLE-SIZE REGRESSION, which was the reason to suspect the narrow import existed.
* The build NARROWS library/i18n's catalogue glob per bundle to the areas `internal/i18n-areas.mjs`
* finds in that bundle's source graph (see check:i18n-treeshake). A bundle that references the pplr area gets the
* pplr area and not the other thirty. Verified before writing this, because "the obvious dedup makes
* every bundle carry every locale" would have been a real objection if the glob were still wide.
*
* Order of resolution, and each step earns its place:
* 1. the host's own vue-i18n, when it installed one — so a host that manages locale keeps control;
* 2. `tStatic`, which honours the runtime/page locale and falls back to English;
* 3. the key itself, so a missing string is visible as a key rather than as a blank.
* Step 2 also covers the case i18n IS installed but has no entry: `i18n.t(key)` returns the key
* unchanged, which every copy of this pattern already treated as "not found".
*
* @param {string} namespace the catalogue area name (a bare area, no dotted key) — used only to prefix bare keys
* @returns {(key: string, params?: object) => string}
*/
import { useI18n } from 'vue-i18n'
import { tStatic, getRuntimeLocale, cldrChoiceIndex } from '@map/i18n/index.js'
/** `{n}` interpolation. Exported so a gate can test it without a Vue setup scope. */
export function fillParams(str, params) {
if (!params) return String(str)
// ★ `!= null`, NOT `k in params`. Gospel's interpolate() had it right and my first version did not:
// with { count: null } a key-presence test renders the STRING "null" into the sentence, while this
// leaves `{count}` visible. A visible placeholder is a bug you can see and fix; "null" reads as
// content. Found by comparing against charts.vue before migrating it, not after.
return String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
}
/**
* Pick the form of a pipe-separated plural string for a count, the way an installed vue-i18n would:
* English by vue-i18n's OWN default rule (English sits outside the CLDR map), every other locale by the
* SAME CLDR selection library/i18n/index.js installs (cldrChoiceIndex: ru 4 forms, ar 6, zh 1). So a
* host without i18n picks the same form a host with it does, in every language — regions caught that
* the English rule alone would print the wrong Russian and Arabic forms there. No pipe: returned whole.
*/
export function pickPluralForm(str, n, locale = getRuntimeLocale()) {
const forms = String(str).split(/\s*\|\s*/)
if (forms.length < 2 || typeof n !== 'number') return String(str)
const lc = String(locale || 'en').split('-')[0].toLowerCase()
if (lc !== 'en') return forms[cldrChoiceIndex(n, forms.length, lc)]
const c = Math.abs(n)
const i = forms.length === 2 ? (c ? (c > 1 ? 1 : 0) : 1) : (c ? Math.min(c, 2) : 0)
return forms[Math.min(i, forms.length - 1)]
}
/** Prefix a bare key with its area, so callers may pass a bare key or an already-qualified one. */
export function qualify(namespace, key) {
const k = String(key)
return namespace && !k.startsWith(`${namespace}.`) ? `${namespace}.${k}` : k
}
/**
* ★ `fallback` EXISTS BECAUSE THE FIVE LIBRARY COMPONENTS DO NOT ALL FALL BACK TO THE CATALOGUE, and
* swapping what they fall back to is a PRODUCT change, not a consolidation. Measured:
* ApproxPinNote / FilterDropdown a literal fallback MAP
* SemanticTreeLegend / PosterWall humanizeKey() — a dotted legend column key renders as its last segment, title-cased
* PinHint a one-key fallback map
* Routing those through tStatic would change what seven bare-host bundles render (and, because tStatic
* honours the runtime locale, would start translating strings that are English today). That may well be
* an improvement, but it is a separate decision from "store this fact once", so each site passes the
* fallback it already had and renders byte-identically.
* @param {(key:string, params?:object)=>string} [opts.fallback] used instead of tStatic when the host
* has no i18n, or when its catalogue lacks the key. Omit it to get the catalogue (the default).
*/
// ★ THE THIRD ARGUMENT IS A PLURAL COUNT, and it is forwarded to vue-i18n, whose pluralRules map
// (library/i18n/index.js) picks each locale's CLDR form: ru 4, ar 6, zh 1. Without it a pipe string
// renders one form for every number. Omitted, the call is exactly the two-argument call it always was.
export function useOptionalT(namespace, opts = {}) {
const viaFallback = typeof opts.fallback === 'function'
? (key, params, plural) => fillParams(pickPluralForm(opts.fallback(qualify(namespace, key), params) ?? key, plural), params)
: (key, params, plural) => fillParams(pickPluralForm(tStatic(qualify(namespace, key)) ?? key, plural), params)
try {
const i18n = useI18n()
return (key, params, plural) => {
const q = qualify(namespace, key)
const v = typeof plural === 'number' ? i18n.t(q, params || {}, plural) : i18n.t(q, params || {})
return v === q ? viaFallback(key, params, plural) : v
}
} catch (_) {
// No i18n on this host. Not an error: seven bundles deliberately install none.
return viaFallback
}
}
/**
* The same guard, for callers that need more than `t`.
*
* ★ `te` KEEPS ITS EXISTING CONTRACT DELIBERATELY: on a host with no i18n it answers FALSE, never
* "true because the catalogue has it". PinHint and SemanticTreeLegend both record why — "te becomes a
* 'no, I don't have it' so every caller falls through to its own default". Making it consult tStatic
* would silently take those callers off their own defaults, which is the same class of mistake as
* moving a fallback.
*
* @returns {{ t: Function, te: (key:string)=>boolean, locale: any }}
*/
export function useOptionalI18n(namespace, opts = {}) {
const t = useOptionalT(namespace, opts)
try {
const i18n = useI18n()
return { t, te: (key) => i18n.te(qualify(namespace, key)), locale: i18n.locale }
} catch (_) {
return { t, te: () => false, locale: opts.locale ?? 'en' }
}
}
export default useOptionalT