A bare-host embed under ?lang=ar now renders Arabic here too

dx-shepherd's ruling: the locale-honouring fallback everywhere, carrying the
Driver's goal (d) — a bare-host embed with ?lang=ar must render Arabic — now
recorded in COMMON.md as the embed contract.

all-people-groups passed its inline EN map as the fallback, which served ENGLISH to
exactly the hosts that cannot install vue-i18n: the seven bundles here that install
none. It now takes the catalogue default, so those readers get their own language
with English still the last resort. The EN map is deleted — with the catalogue
complete it could rescue nothing, and a second copy of the English is only a place
for the two to drift.

THE RULING'S CONDITION WAS MEASURED BEFORE THE SWITCH, not after: all eleven keys
exist in all eleven locales, and ar differs from en on 11 of 11, so the change is
observable rather than cosmetic.

★ AND THE CONDITION IS NOW KEPT TRUE RATHER THAN MEASURED ONCE. A hand census
protects the day it is taken. Section 9 of check:optional-t-equivalence re-derives
it every run, because with no local fallback an uncatalogued key renders as a raw
dotted path to a reader — the exact failure upg100 described, and the reason they
had advised keeping a fallback at all.

★ THAT CENSUS UNDER-COUNTED TWICE BEFORE IT WAS RIGHT, both caught by checking
instead of assuming:
 - literal tr('key') extraction found 6 of 11. Four keys are category ids reached
   only through tr(cat.id) / tr(c.id); the census now extracts the CATS ids too, and
   asserts the dynamic call sites still look the way it assumes.
 - the eleventh, 'pray', was lost to codeOnly: it sits in a template literal whose
   `https://` was read as a line comment, because the scanner had already lost
   template state further up a 1000-line SFC. The census now reads RAW source.
   The asymmetry is the point — over-including a commented-out key makes the check
   STRICTER (a cheap false red); under-including a real one makes it claim
   completeness it does not have. strip-code.mjs carries that rule now, so the next
   author picks raw or stripped for a reason rather than by habit.
This commit is contained in:
Doxa Maps 2026-09-27 01:36:30 +00:00
parent 0fa6d1fe63
commit 0ded1a4e1b
3 changed files with 81 additions and 19 deletions

View file

@ -101,7 +101,7 @@ ok(!/locales\/en\//.test(codeOnly(src)), 'CONTROL: and it imports NO per-area En
// the shared guard, and that it still carries ITS OWN fallback rather than silently inheriting the
// catalogue. The second matters more than it looks: routing these through tStatic would change what
// seven bare-host bundles render, and would start translating strings that are English today.
import { readFileSync as rf } from 'node:fs'
import { readFileSync as rf, readdirSync } from 'node:fs'
const SITES = [
// file, helper it must use, a fragment of the fallback it must still pass
['library/components/legends/ApproxPinNote.vue', 'useOptionalT', 'APPROX_FALLBACK[key]'],
@ -157,10 +157,13 @@ const SITES = [
['plugin-designs/my-upg-100-list/upg-100-mfe.vue', 'useOptionalT', 'humanizeKey(k)'],
// wave 3d — all-people-groups. Treated as UNOWNED (studio agreed), so the conservative reading wins:
// its inline EN map stays the fallback rather than switching to the catalogue, because nobody is
// available to decide that this bundle should start honouring the locale on a bare host. An unowned
// file is the wrong place to make a product decision by default.
// available to decide that this bundle should start honouring the locale on a bare host -- THEN
// dx-shepherd RULED the locale-honouring fallback everywhere (2026-09-27; the Driver's goal (d), a
// bare-host embed with ?lang=ar must render Arabic, now the embed contract in COMMON.md). So it takes
// the catalogue default, its duplicate EN map is deleted, and section 9 keeps the ruling's PRECONDITION
// true rather than measured once.
// First site on the {t, te, locale} form: it needs locale for toLocaleString.
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 'fallback: (full) =>'],
['plugin-designs/all-people-groups/all-people-groups.vue', 'useOptionalI18n', 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
@ -200,9 +203,12 @@ for (const [file, helper, fallbackFrag] of SITES) {
// 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')
// Which sites were MOVED to the catalogue by an owner's decision, vs which always resolved there.
// Saying "as it always did" about a moved file is a false statement inside a gate message, and a
// gate message is the one place a reader has no reason to doubt.
const moved = ['gospel-resources', 'cities-internet', 'all-people-groups'].some((d) => file.includes(d))
ok(!/fallback:/.test(code), moved
? `${short}: takes the locale-honouring CATALOGUE fallback (gospel-plugin moved it here from enLookup's English)`
? `${short}: takes the locale-honouring CATALOGUE fallback (moved here from its own English by its owner's decision, or by dx-shepherd's embed-contract ruling)`
: `${short}: takes the CATALOGUE fallback, as it always did`)
}
}
@ -239,6 +245,52 @@ for (const [file, helper, fallbackFrag] of SITES) {
'ENGLISH REMAINS THE LAST RESORT: an unknown locale falls back to en, as gospel required')
}
// ── 9. THE RULING'S PRECONDITION, kept true rather than measured once ───────────────────────────
// Dropping a site's own English fallback is only safe while the catalogue HAS every key that site asks
// for. Before the switch I measured it by hand: 11 keys, 11 locales, 0 gaps. A hand measurement protects
// the day it is taken; this protects every day after, which is the whole difference between a check and
// a note. If a key is added to the source and not catalogued, a bare host now renders the raw dotted
// key -- the exact failure upg100 described, and the reason they had advised keeping a fallback at all.
{
const src = rf(new URL('../plugin-designs/all-people-groups/all-people-groups.vue', import.meta.url), 'utf8')
// ★ RAW SOURCE, NOT codeOnly, AND THE ASYMMETRY IS THE REASON. For "does the catalogue hold every key
// this file asks for", over-including a key (one that only appears in a comment) makes the check
// STRICTER and can only cause a false red that a human resolves in seconds. UNDER-including one makes
// it claim completeness it does not have, which is the failure this section exists to prevent.
// It matters concretely: codeOnly loses `tr('pray')` in this file. That call lives inside a template
// literal whose `https://` was read as a line comment, because the scanner had already lost template
// state further up a 1000-line SFC. See the limitation note in strip-code.mjs. Six keys became seven
// the moment the census stopped stripping.
const code = src
const literal = [...new Set([...code.matchAll(/\btr\(\s*'([A-Za-z][\w.]*)'/g)].map((m) => m[1]))]
// ★ LITERALS ARE NOT ALL OF THEM, and a census that stopped here would have claimed completeness while
// blind to a third of the keys. This file also calls tr(cat.id) and tr(c.id) -- the category ids from
// the CATS array -- so four keys (doxa, frontier, unreached, reached) never appear as a literal
// argument. Extract those ids too, and assert BOTH that they were found and that the dynamic call
// sites still look the way this extraction assumes.
const catIds = [...new Set([...code.matchAll(/\{\s*id:\s*'([a-z]+)'\s*,\s*color:/g)].map((m) => m[1]))]
const dynamic = [...new Set([...code.matchAll(/\btr\(\s*([A-Za-z_$][\w.$]*)/g)].map((m) => m[1]))]
ok(dynamic.length === 0 || catIds.length > 0,
`the dynamic tr() arguments (${dynamic.join(', ') || 'none'}) are covered by the CATS ids (${catIds.join(', ') || 'NONE FOUND'})`)
ok(catIds.length === 4,
`found ${catIds.length} category ids -- if this changes, the dynamic keys this census covers have changed with it`)
const used = [...new Set([...literal, ...catIds])]
ok(used.length > literal.length,
`all-people-groups: ${used.length} keys verified = ${literal.length} literal + ${catIds.length} reached only through tr(cat.id)`)
const locales = readdirSync(new URL('../library/i18n/locales', import.meta.url))
const gaps = []
for (const k of used) for (const loc of locales) {
let v
try { v = JSON.parse(rf(new URL(`../library/i18n/locales/${loc}/allPeopleGroups.json`, import.meta.url), 'utf8'))[k] } catch (_) { v = undefined }
if (v === undefined || v === '') gaps.push(`${loc}:${k}`)
}
ok(gaps.length === 0,
`every key it asks for exists in all ${locales.length} locales${gaps.length ? ` -- MISSING ${gaps.slice(0, 6).join(', ')}` : ''}`)
let invented
try { invented = JSON.parse(rf(new URL('../library/i18n/locales/en/allPeopleGroups.json', import.meta.url), 'utf8')).definitelyNotACataloguedKey } catch (_) {}
ok(invented === undefined, 'NEG CONTROL: an invented key is absent, so the lookup above can actually fail')
}
// ── 8. the fallback option and te/locale, behaviourally ────────────────────────────────────────
{
const { useOptionalT, useOptionalI18n } = make(tStatic, throwingI18n)

View file

@ -102,26 +102,27 @@ const mapProvider = resolveMapProvider(profileConfig)
const assetsBase = computed(() => resolveAssetsBase(profileConfig?.value || {}))
// ─── Words (library/i18n/locales/<lc>/allPeopleGroups.json, all 11 locales) ────
// Guarded like FilterDropdown: ProfileLoader installs vue-i18n for this profile, but an unguarded
// useI18n() throws on any host without it, and this map must never go blank over a label.
const EN = { title: 'All People Groups', locations: '{n} group–country locations', doxa: 'DOXA people groups',
frontier: 'Frontier — nothing started', unreached: 'Unreached — work beginning', reached: 'Reached — church is there',
hint: 'Click a row to see all of them · click again for everything', loading: 'Loading every people group…',
loadError: 'The map could not be loaded.', pray: 'Pray for them →', population: 'pop {n}' }
// The English for these eleven strings lives in library/i18n/locales/en/allPeopleGroups.json and
// nowhere else. An inline EN map used to duplicate it here as the bare-host fallback; with the
// locale-honouring default that copy had no job left, and a second copy of a catalogue is only a
// place for the two to drift apart.
// C4 wave 3d: the guard is library/composables/useOptionalI18n now — this site needs more than `t`,
// so it takes the {t, te, locale} form rather than useOptionalT.
// ★ EN STAYS THE FALLBACK, passed in. It is an inline English map, not the catalogue, so routing this
// to tStatic would change what a bare host renders. gospel-plugin and exp-shepherd each CHOSE the
// locale-honouring default for their own bundles; all-people-groups has no owner to ask
// (studio agreed to treat it as unowned), so the conservative reading wins: preserve what it renders.
// ★ THE LOCALE-HONOURING FALLBACK, per dx-shepherd's ruling (2026-09-27) and the Driver's goal (d):
// a BARE-HOST EMBED WITH ?lang=ar MUST RENDER ARABIC. Recorded in COMMON.md as the embed contract.
// This file used to pass its inline EN map as the fallback, which served ENGLISH to exactly the hosts
// that cannot install vue-i18n — the seven bundles here that install none. tStatic honours the runtime
// locale and keeps English as the last resort, so those readers now get their own language.
// ★ THE RULING'S CONDITION WAS MEASURED BEFORE THE SWITCH, not after: all ELEVEN keys this file uses
// are present in all ELEVEN locales (0 gaps, counted 2026-09-27), and ar differs from en on 11 of 11,
// so the change is observable rather than cosmetic. With the catalogue complete there is nothing the
// EN map could still rescue: a key it holds is a key tStatic also holds.
// The keys arrive BARE here ('hint', 'pray'), and qualify() adds the 'allPeopleGroups.' prefix, which
// is exactly what the hand-written `const full = 'allPeopleGroups.' + key` did.
// ★ ONE EQUIVALENCE WORTH STATING: the old code asked te() first and only then t(). The helper calls
// t() and falls back when it returns the key unchanged. Same outcome both ways — present key: the
// translation; absent key: EN — and it no longer depends on te() being implemented.
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups', {
fallback: (full) => EN[String(full).replace(/^allPeopleGroups\./, '')]
})
const { t: tr, locale: i18nLocale } = useOptionalI18n('allPeopleGroups')
// locale arrives as a REF when the host installed i18n and a plain string when it did not
// (PosterWall.vue records the same variance), so unwrap both shapes.
const uiLocale = computed(() => {

View file

@ -27,6 +27,15 @@
// off such a file once gave (0,5,53) because a comment landed inside the selector.
// - HTML comments in a .vue TEMPLATE. `<!-- ... -->` is invisible here, so any identifier quoted in
// one is counted as code. That made a bare-useI18n census read 3 where the code had 2.
// - A LONG SFC CAN DESYNCHRONISE IT. Measured 2026-09-27 in all-people-groups.vue: `tr('pray')` inside
// a template literal was lost, because the `//` of an `https://` URL in that template was read as a
// line comment -- the scanner had already lost template state somewhere earlier in a 1000+ line file.
// The narrow cases all pass (a backtick template, a URL in a template, a nested ${} with a quote, a
// regex holding a quote), so this is an accumulation problem, not one of those.
// ★ THE RULE THAT FOLLOWS: when the question is "does X exist anywhere", prefer RAW source. Over-
// including a commented-out hit makes a check STRICTER (a false red, cheap). Under-including a real
// hit makes it claim completeness it does not have (a false green, expensive). Only use this stripper
// when a comment mentioning the pattern would be a FALSE POSITIVE you cannot tolerate.
// gospel-plugin's generalisation, worth keeping: "any <!-- --> in a template that quotes an identifier
// will keep doing that to you". THE RULE: the stripper must match the LANGUAGE of the text being read.
// For a .vue file, strip HTML comments first, then call this for the script block.