doxa-maps/library/composables/useOptionalT.js
Doxa Maps 9816a4244f gospel takes the locale-honouring fallback, and it is their call that made it
gospel-plugin's decision, in their words: "most partners will drop a custom
element or an iframe and never install vue-i18n ... an Arabic ministry site
embedding this map serves its readers English while the bundle already carries all
11 catalogues. That is a bug, not a decision."

So all four gospel translators drop `{ fallback: enLookup }` and take the
helper's catalogue default, which resolves through tStatic: the reader's locale
first, English as the last resort. Their three conditions are met and each is
asserted, not assumed:
  1. resources-map passes NO namespace — its keys span gospel.* and pins.*;
  2. the fallback walks the catalogue ROOT, which tStatic does by construction;
  3. params survive, via the null-safe fillParams.

★ THE ROW THEY ASKED FOR, and it is the only thing here that could catch the
regression this migration risked: a BARE host under ar must render
pins.approximate in ARABIC — not the key, not gospel.pins.approximate, not the
English. It reads the SHIPPED ar/ and en/ pins.json and rebuilds tStatic's own
resolution order over them, so it measures the real catalogue rather than a stub.
Three negatives beside it, plus a control that both catalogues really carry the
key, so the comparison is measuring a difference that exists.

enLookup is deleted from all four, and the EN_MESSAGES import from three —
data-tables keeps it, because it still builds a map from EN_MESSAGES.gospel.res at
:182. Checked before deleting rather than after: check:no-free-identifiers would
have caught a wrong removal, since removing something still used creates a name
that is valid JS until it runs.

Two corrections that came from other agents reading this work:
 - studio: the JSDoc carried <area>.<key>-shaped examples, which
   check:i18n-keys-exist reads as real keys and reports missing. They reworded it
   twice because my file copy restored it. Reworded, with a standing note in the
   file so a third copy cannot bring them back.
 - the register said each site "takes the CATALOGUE fallback, as it always did".
   True of the two composables, FALSE of gospel's four, which were moved here by
   their owner. A false statement inside a gate message is worse than no message,
   because a reader has no reason to doubt it. Now accurate per row.

check:i18n-keys-exist is red on messages.engineNotLoadedDetail. NOT mine and not
touched here: it is in cities-internet/connectivity-map.vue, whose own comment says
the key "arrives with upg100's ade12e4c", and the call already guards for its
absence.
2026-09-26 22:06:59 +00:00

109 lines
6.1 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 } 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))
}
/** 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).
*/
export function useOptionalT(namespace, opts = {}) {
const viaFallback = typeof opts.fallback === 'function'
? (key, params) => fillParams(opts.fallback(qualify(namespace, key), params) ?? key, params)
: (key, params) => fillParams(tStatic(qualify(namespace, key)) ?? key, params)
try {
const i18n = useI18n()
return (key, params) => {
const q = qualify(namespace, key)
const v = i18n.t(q, params || {})
return v === q ? viaFallback(key, params) : 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