C4 wave 3a, gospel-resources: four guards share one, English unchanged

All four of gospel's translators had the same shape — enLookup + a try/catch
around useI18n() + `v === key ? fallback : v` — and all four now call
useOptionalT('gospel', { fallback: enLookup }).

enLookup STAYS as each site's fallback rather than taking the helper's catalogue
default. The difference is real and it is not mine to decide: enLookup resolves
against the SHIPPED ENGLISH, while the catalogue default resolves through tStatic,
which honours the runtime locale. Adopting the default would make a bare host
render Arabic under ?lang=ar where it renders English today. That is arguably
better, and it belongs to gospel-plugin.

★ AND ONE FIX THAT CAME OUT OF READING THEIR CODE BEFORE REPLACING IT.
charts.vue's interpolate() substitutes only when a value is NOT NULL; my
fillParams substituted whenever the KEY WAS PRESENT. With { count: null } mine
would have rendered the string "null" into a sentence where theirs leaves
`{count}` visible. Theirs is right — a visible placeholder is a bug you can see,
"null" reads as content — so fillParams now matches, with assertions for the null
case and for the 0 case a truthiness test would have dropped.

resources-map.vue KEEPS two bare useI18n() calls, deliberately: they read the
LOCALE (`useI18n().locale.value`) for isEnglish and the engine's language tag, not
text. `locale` arrives as a ref or a plain string depending on the host —
PosterWall records the same variance — so normalising it belongs in a considered
helper change, not here. The register asserts EXACTLY two, so a third bare call
still fails.

check:no-free-identifiers earned its keep on this batch: replacing that file's
vue-i18n import while two calls still referenced useI18n left a name that is valid
JS until it runs. Caught before commit, both imports now present.

The per-site register in check:optional-t-equivalence gains four rows (59
assertions).
This commit is contained in:
Doxa Maps 2026-09-26 18:14:36 +00:00
parent 63ab6719d2
commit e04121f16f
6 changed files with 50 additions and 29 deletions

View file

@ -63,6 +63,10 @@ const hostI18n = (table) => () => ({ t: (k) => (k in table ? table[k] : k) })
const { fillParams, qualify } = make(tStatic, throwingI18n)
ok(fillParams('a {x} b', { x: 1 }) === 'a 1 b', 'fillParams interpolates')
ok(fillParams('a {x} b', {}) === 'a {x} b', 'fillParams leaves an unknown placeholder visible rather than blanking it')
ok(fillParams('a {x} b', { x: null }) === 'a {x} b',
'fillParams leaves the placeholder for a NULL value -- a key-presence test would render the string "null" into the sentence')
ok(fillParams('a {x} b', { x: 0 }) === 'a 0 b',
'but 0 is a real value and must still substitute (a truthiness test would drop it)')
ok(fillParams('plain') === 'plain', 'fillParams with no params is a no-op')
ok(qualify('pplr', 'x') === 'pplr.x' && qualify('pplr', 'pplr.x') === 'pplr.x', 'qualify prefixes once, never twice')
}
@ -107,12 +111,31 @@ const SITES = [
['library/components/hints/PinHint.vue', 'useOptionalI18n', 'PINHINT_FALLBACK[key]'],
['plugin-designs/pplr-data-maps/src/composables/usePplrT.js', 'useOptionalT', null],
['plugin-designs/upg-neighbor/src/composables/useNeighborT.js', 'useOptionalT', null],
// wave 3a — gospel-resources (owner: gospel-plugin). All four fell back to enLookup, i.e. the
// 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'],
['plugin-designs/gospel-resources/resources-map.vue', 'useOptionalT', 'fallback: enLookup'],
]
// ★ 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
// engine's language tag. That is a different fact from translating text: `locale` arrives as a ref OR
// a plain string depending on the host (PosterWall.vue records the same variance), so normalising it
// belongs in a deliberate helper change, not in a translator migration. Asserting EXACTLY 2 means a
// third bare call — a new unguarded translator — still fails this gate.
const ALLOWED_BARE = { 'plugin-designs/gospel-resources/resources-map.vue': 2 }
for (const [file, helper, fallbackFrag] of SITES) {
const code = codeOnly(rf(new URL('../' + file, import.meta.url), 'utf8'))
const short = file.split('/').pop()
ok(new RegExp(`\\b${helper}\\(`).test(code), `${short}: routes through ${helper}()`)
ok(!/useI18n\(/.test(code), `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
const bare = (code.match(/useI18n\(/g) || []).length
const allowed = ALLOWED_BARE[file] || 0
ok(bare === allowed, allowed
? `${short}: exactly ${allowed} bare useI18n() left, the documented locale read(s) -- found ${bare}`
: `${short}: no bare useI18n() left in CODE (comments explaining it are fine)`)
if (fallbackFrag) {
ok(code.includes(fallbackFrag),
`${short}: still passes its OWN fallback (${fallbackFrag}) -- migrating it to the catalogue would change what a bare host renders`)

View file

@ -38,7 +38,11 @@ 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)
return String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m))
// ★ `!= 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 either 'x.y' or 'pplr.x.y'. */

View file

@ -45,7 +45,7 @@
* DATA: fetched at runtime from assets/tables/ (people-group-resources.json is 1.2MB;
* inlining it into the IIFE would block first paint for every profile in the bundle).
*/
import { useI18n } from 'vue-i18n'
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'
@ -64,14 +64,10 @@ const enLookup = (key) =>
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
let tr = (key, params) => interpolate(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
tr = (key, params) => {
const v = params ? i18n.t(key, params) : i18n.t(key)
return v === key ? interpolate(enLookup(key) ?? key, params) : v
}
} catch (_) { /* no i18n on this host */ }
// 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 })
const profileConfig = inject('profileConfig', ref(null))

View file

@ -20,7 +20,7 @@
* the table's own meta so the method travels with the number.
*/
import { computed, inject, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import DataTable from '@map/components/tables/DataTable.vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -37,11 +37,10 @@ const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
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 || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the shipped English catalogue is the fallback */ }
// 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 })
const TABS = [
// ★ THE DOXA TABLE LEADS, because it is the question the Driver actually asked: which of

View file

@ -1,5 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { resourceRows } from './gospel-resource-rows.js'
/**
@ -46,11 +46,10 @@ import { resolveAssetsBase } from '@map/utils/embedBase.js'
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 || {})
let tr = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
tr = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host */ }
// 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 })
const profileConfig = inject('profileConfig', ref(null))

View file

@ -71,7 +71,8 @@
* disagree, and the 5-country string above parses to exactly its 5 real names.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useI18n } from 'vue-i18n' // still needed: two guarded LOCALE reads below (isEnglish, the engine tag)
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { applyPeopleGroupList } from '@map/lists/peopleGroupList.js'
import { EN_MESSAGES } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -784,11 +785,10 @@ const pinsByIso = computed(() => {
broken-looking label is not graceful degradation, it just moves the bug. */
const enLookup = (key) =>
String(key).replace(/^gospel\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), EN_MESSAGES.gospel || {})
let t = (key) => enLookup(key) ?? String(key)
try {
const i18n = useI18n()
t = (key) => { const v = i18n.t(key); return v === key ? (enLookup(key) ?? key) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// 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 t = useOptionalT('gospel', { fallback: enLookup })
/* 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 = (() => {