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.
This commit is contained in:
Doxa Maps 2026-09-26 18:24:04 +00:00
parent 0d0ee3cbed
commit 9816a4244f
7 changed files with 79 additions and 29 deletions

View file

@ -147,7 +147,7 @@ and a change here can break other maps.
`import … from '@map/composables/useMapSelection.js'`
- **useMapTheme** — useMapTheme.js — shared light/dark theme for every map profile.
`import … from '@map/composables/useMapTheme.js'`
- **useOptionalT** — useOptionalT(namespace) — one guarded translator, for the twenty places that each stored the same
- **useOptionalT** — ★ NO <area>.<key>-SHAPED EXAMPLES IN THIS FILE'S COMMENTS. check:i18n-keys-exist reads any such
`import … from '@map/composables/useOptionalT.js'`
- **usePosterPrints** — usePosterPrints.js — THE print actions every poster wall shares.
`import … from '@map/composables/usePosterPrints.js'`

View file

@ -115,15 +115,20 @@ const SITES = [
// SHIPPED ENGLISH, not the locale-aware catalogue; each passes that enLookup in, so a bare host
// still renders English. Switching them to the catalogue default would make a bare host honour
// ?lang=ar — a real improvement, and gospel-plugin's to make, not this migration's.
['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', 'fallback: enLookup'],
['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', 'fallback: enLookup'],
['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', 'fallback: enLookup'],
// ★ gospel-plugin CHOSE the locale-honouring catalogue fallback (2026-09-26) rather than keeping
// enLookup's English: "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 these four take NO
// fallback, and row 7b proves the Arabic actually arrives.
['plugin-designs/gospel-resources/charts.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/data-tables.vue', 'useOptionalT', null],
['plugin-designs/gospel-resources/resources-list.vue', 'useOptionalT', null],
// ★ resources-map passes NO NAMESPACE: it uses keys in TWO areas (gospel.* and pins.*) and its
// enLookup walks the catalogue ROOT to see both. useOptionalT('gospel', ...) would have qualified
// pins.approximate into gospel.pins.approximate and broken the pins sentence — studio caught that
// as a semantic conflict on main before it landed. The row asserts the '' explicitly so a later
// edit cannot quietly re-add a namespace.
['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", 'fallback: enLookup'],
['plugin-designs/gospel-resources/resources-map.vue', "useOptionalT(''", null],
]
// ★ ONE FILE KEEPS TWO BARE useI18n CALLS ON PURPOSE, and the count is asserted rather than waived.
// resources-map.vue reads the LOCALE twice (`useI18n().locale.value`) to decide isEnglish and the
@ -151,10 +156,49 @@ for (const [file, helper, fallbackFrag] of SITES) {
ok(code.includes(fallbackFrag),
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)
} else {
ok(!/fallback:/.test(code), `${short}: takes the CATALOGUE fallback, as it always did`)
// ★ NOT "as it always did" for all of them. The two composables always resolved through the
// catalogue; gospel's four used to resolve through enLookup's ENGLISH and were MOVED here by their
// owner's decision. Saying "as it always did" for those four would be a false statement inside a
// gate message, which is the one place a reader has no reason to doubt.
const moved = file.includes('gospel-resources')
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
// ── 7b. gospel-plugin's ASKED-FOR ROW, against the REAL catalogue ───────────────────────────────
// Their words: "a BARE host (no vue-i18n) with ?lang=ar must render pins.approximate in Arabic — not
// the key, not gospel.pins.approximate, not English." It covers their conditions 1 and 2 together: a
// namespace would corrupt the key, and an EN-only fallback would serve English to an Arabic reader.
// This reads the SHIPPED ar/ and en/ pins.json rather than a stub, and rebuilds tStatic's own
// resolution order (messages[want] ?? messages.en) over them, so it measures the real catalogue.
{
const arPins = JSON.parse(rf(new URL('../library/i18n/locales/ar/pins.json', import.meta.url), 'utf8'))
const enPins = JSON.parse(rf(new URL('../library/i18n/locales/en/pins.json', import.meta.url), 'utf8'))
const messages = { ar: { pins: arPins }, en: { pins: enPins } }
const realish = (want) => (path) => {
const walk = (loc) => String(path).split('.').reduce((o, k) => (o == null ? o : o[k]), messages[loc])
const v = walk(want); return typeof v === 'string' ? v : walk('en')
}
const { useOptionalT } = make(realish('ar'), throwingI18n)
const t = useOptionalT('')
const got = t('pins.approximate')
ok(got === arPins.approximate,
'GOSPEL ROW: a BARE host under ar renders pins.approximate in ARABIC from the real catalogue')
ok(got !== 'pins.approximate' && got !== 'gospel.pins.approximate',
'and it is neither the raw key nor a namespace-mangled key')
ok(got !== enPins.approximate,
'and it is NOT the English -- which is exactly what enLookup used to serve every partner host')
// the same call must still reach English when the locale has no entry
const tEnOnly = useOptionalT('')
ok(typeof arPins.approximate === 'string' && typeof enPins.approximate === 'string',
'CONTROL: both catalogues really do carry the key, so the three assertions above are measuring a difference that exists')
const { useOptionalT: uMissing } = make(realish('zz'), throwingI18n)
ok(uMissing('')('pins.approximate') === enPins.approximate,
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)

View file

@ -1,4 +1,8 @@
/**
* ★ 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.
*
@ -10,15 +14,15 @@
*
* ★ WHY THIS DOES NOT TAKE A FALLBACK CATALOGUE, though the curation spec specified
* `(namespace, fallbackCatalogue)`. Every existing copy imports its own English JSON
* (`import enPplr from '@map/i18n/locales/en/pplr.json'`) and hand-walks it after stripping the
* (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(area + '.' + key)` already
* `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 says 'pplr.' gets the
* 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.
*
@ -29,7 +33,7 @@
* 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, e.g. 'pplr' — used only to prefix bare keys
* @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'
@ -45,7 +49,7 @@ export function fillParams(str, params) {
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 one that already carries its area prefix. */
/** 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
@ -55,7 +59,7 @@ export function qualify(namespace, key) {
* ★ `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() — 'legend.columns.name' renders as 'Name'
* 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

View file

@ -46,15 +46,12 @@
* inlining it into the IIFE would block first paint for every profile in the bundle).
*/
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { computed, inject, ref, watch } from 'vue'
import BarChart from '@map/components/charts/BarChart.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
and the shipped English catalogue is the fallback rather than a broken key. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
/* ★ NUMBERS ARRIVE AS PARAMETERS, NEVER CONCATENATED. The subtitles carry a count, and a
sentence built by gluing a number between two fragments cannot be translated — word order
differs and several of these locales put the number where English does not. The catalogue
@ -62,12 +59,13 @@ const enLookup = (key) =>
THE FALLBACK HAS TO INTERPOLATE TOO. Without it a host that never installed i18n would render
the placeholder literally — "Across all {n} languages" on screen — which is worse than the
English sentence the fallback exists to protect. */
const interpolate = (str, params) =>
params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? params[k] : m)) : str
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
const tr = useOptionalT('gospel', { fallback: enLookup })
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const profileConfig = inject('profileConfig', ref(null))

View file

@ -35,12 +35,13 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
so a translator called t() would be shadowed inside the one block that needs it most, and
the labels would silently render as whatever the tab object stringifies to. Guarded like
every other useI18n() call in this bundle: the host may never have installed i18n. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
const tr = useOptionalT('gospel', { fallback: enLookup })
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const TABS = [
// ★ THE DOXA TABLE LEADS, because it is the question the Driver actually asked: which of

View file

@ -1,6 +1,5 @@
<script setup>
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { resourceRows } from './gospel-resource-rows.js'
/**
* resources-list.vue — GOSPEL RESOURCES, by language.
@ -44,12 +43,13 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
/* Guarded like every other useI18n() in this bundle — the host may never have installed it,
and the shipped English catalogue is the fallback rather than a broken key. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
// C4: the guard is library/composables/useOptionalT.js now. enLookup STAYS as the fallback, passed
// in, so a bare host renders the same ENGLISH it always did -- routing this to the catalogue default
// would make a bare host honour ?lang=ar, which is gospel-plugin's call and not this migration's.
const tr = useOptionalT('gospel', { fallback: enLookup })
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback rather than
// enLookup's English. A partner host that never installed vue-i18n now gets the reader's language,
// with English as the last resort. tStatic resolves 'gospel.<key>' against the same catalogue.
const tr = useOptionalT('gospel')
const profileConfig = inject('profileConfig', ref(null))

View file

@ -74,7 +74,6 @@ import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js' // vue-i18n stays: two guarded LOCALE reads below
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
import { engineUiLocale } from '@map/composables/useMapInstance.js'
@ -786,8 +785,6 @@ const pinsByIso = computed(() => {
/* Walks from the ROOT of the catalogue, not from its gospel subtree: this leaf now also reads
`pins.*`, the shared people-group-pin wording, and an area-blind lookup would have fallen back
to the key name for every one of those on a host with no i18n. */
const enLookup = (key) =>
String(key).split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES || {})
/* The fallback interpolates too. vue-i18n does it for the translated path; without this the
English arm would render "{names} — {n} groups share…" literally. */
const fill = (str, params) => (params
@ -800,7 +797,13 @@ const fill = (str, params) => (params
// pins.approximate into gospel.pins.approximate on the translated path and sent the fallback looking
// under EN_MESSAGES.gospel, where pins.* does not exist. studio caught it as a SEMANTIC conflict
// before it landed; passing '' keeps every key exactly as the caller wrote it.
const t = useOptionalT('', { fallback: enLookup })
// C4 wave 3a, gospel-plugin's call (2026-09-26): take the LOCALE-HONOURING fallback. Their reason is
// the bundle's purpose -- most partners drop an element or an iframe and never install vue-i18n, so
// those hosts got enLookup's ENGLISH while the bundle already shipped all 11 catalogues. "That is a
// bug, not a decision." tStatic honours the runtime locale and keeps English as the LAST resort.
// ★ NO NAMESPACE: this leaf passes FULL keys in two areas (gospel.* and pins.*), so any prefixing
// would ask for gospel.pins.approximate, which no catalogue has. studio caught that before it landed.
const t = useOptionalT('')
/* Whether to show the "these notes are in English" line at all. Read from the same i18n
instance the labels come from, so the two can never disagree. */
const isEnglish = (() => {