doxa-maps/internal/check-i18n-never-translated.mjs
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

168 lines
9.3 KiB
JavaScript

#!/usr/bin/env node
/**
* check-i18n-never-translated — a key present in all 11 catalogs but IDENTICAL in all of them is
* probably not translated, only copied.
*
* THE GAP THIS CLOSES. check-i18n-parity asserts PRESENCE — every locale carries every key. A key
* can pass that perfectly while holding the English string in all eleven files, which is what
* "translated" was supposed to mean. Presence and translatedness are different properties, and
* only the first one was measured.
*
* MEASURED 2026-09-25 across every catalog: exactly THREE keys are byte-identical in all locales.
* That the sweep finds three rather than three hundred is what makes it worth gating — it is
* signal, not nagging.
*
* ★ THE DEFECT IT FOUND, and why it is a defect rather than a preference: legend.columns.upgs is
* the literal string "UUPGs" in all 11 catalogs, while `population` sitting beside it in the SAME
* header row translates normally (Población / 人口 / Население / السكان). So the legend renders a
* fully localized row with one English acronym wedged into it.
* THE PRODUCT HAS ALREADY RULED ON THIS CONCEPT. packet-strings.json translates the same idea in
* prose in every locale — "unreached people groups" becomes grupos étnicos no alcanzados / 未得之民 /
* неохваченных народов / شعبًا غير مبلَغ. The legend disagrees with the packet the legend generates.
* That is the "two honest surfaces that disagree" family: each is internally consistent, and
* together they tell a reader the product does not know its own vocabulary.
*
* ⚠ IT IS A HEURISTIC, SO IT CARRIES AN ALLOWLIST. Some keys are CORRECTLY identical everywhere:
* pure interpolation formats with no prose, brand names, ISO designations. The sweep found one —
* legend.progress.segment is "{label} · {count} · {pct}%", all placeholders. Those are declared
* below WITH a reason, so the list is a record of decisions rather than a silencer.
*
* ⚠ AND IT DOES NOT PROPOSE TRANSLATIONS. Inventing terminology across 11 languages is worse than
* leaving an English acronym: a wrong term looks authoritative to a reader who cannot check it.
* Drafting candidates here produced invented initialisms (PNA, UEVG, ННГ) with no source, which is
* exactly the failure this comment exists to prevent. The gate names what is untranslated; a human
* who knows the field's vocabulary supplies the words.
*
* Usage: node internal/check-i18n-never-translated.mjs
*/
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
import { resolve, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = process.env.DOXA_REPO || resolve(HERE, '..')
const BASE = resolve(REPO, 'library/i18n/locales')
// Keys that are CORRECTLY the same in every language. Each needs a reason, so the next reader can
// tell a decision from a suppression.
const ALLOWED = new Map([
['legend.json:progress.segment',
'pure interpolation — "{label} · {count} · {pct}%" contains no prose to translate'],
// ── PROPER NOUNS ────────────────────────────────────────────────────────────
// Names of ORGANISATIONS that publish these resources, not descriptions of them. A reader
// following the link meets the English name on the destination site, so translating it would
// hide the thing it identifies. They sit in the same res.* block as `jesusFilm: 'JESUS Film'`,
// which the product already treats as a brand, and resources-list.vue:234 maps the literal
// string 'Scripture Earth' back to this key — it is an identity, not a label.
['gospel.json:res.bibleStories',
'proper noun — Open Bible Stories is the publication\'s name (unfoldingWord), not a description'],
['gospel.json:res.scriptureEarth',
'proper noun — Scripture Earth is the organisation\'s name; the destination site is English'],
// ── DRIVER-GATED, NOT RESOLVED ──────────────────────────────────────────────
// ⚠ THESE ARE ALLOWED SO THE GATE CAN GO GREEN ON THE THINGS IT HAS ACTUALLY SETTLED, NOT
// because the defect is fixed. "UUPGs" really is an English acronym wedged into an otherwise
// localized row, in Arabic and Chinese included, while `population` beside it translates
// normally and the packet renders the same concept in prose in every locale.
// It is gated on TERMINOLOGY ONLY — measured: the column's own tooltip (legend.sortByUpgs) is
// already translated in all 11 with just the acronym left English, and the widest candidate
// phrase is 28% of the 380px panel, so neither engineering nor space is the blocker. Drafting
// an acronym produced invented initialisms (PNA, UEVG, ННГ, GNA) with no source, and a wrong
// term looks AUTHORITATIVE to a reader who cannot check it.
// See DECIDE-UUPGS-COLUMN-HEADER-IS-ENGLISH-IN-ALL-ELEVEN-LOCALES, which carries the priced
// options. REMOVE THESE TWO LINES when the agent rules — they are a bookmark, not a verdict.
['legend.json:columns.upgs',
'DRIVER-GATED: needs the field\'s term per locale, not an invented acronym — see DECIDE-UUPGS-COLUMN-HEADER'],
['prayerLaps.json:upgs',
'DRIVER-GATED: same term as legend.json:columns.upgs — see DECIDE-UUPGS-COLUMN-HEADER'],
])
// Below this many locales the comparison is not meaningful — a key present in two files being the
// same says nothing.
const MIN_LOCALES = 8
if (!existsSync(BASE)) {
console.log(`I18N-NEVER-TRANSLATED: SKIP — no locale tree at ${BASE}`)
process.exit(0)
}
const langs = readdirSync(BASE).filter((l) => {
try { return statSync(join(BASE, l)).isDirectory() } catch { return false }
}).sort()
if (!langs.includes('en')) {
console.log('I18N-NEVER-TRANSLATED: SKIP — no `en` catalog to compare against')
process.exit(0)
}
const flat = (obj, pre = '') => {
const out = {}
for (const [k, v] of Object.entries(obj || {})) {
const p = `${pre}${k}`
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(out, flat(v, `${p}.`))
else if (typeof v === 'string') out[p] = v
}
return out
}
const read = (lang, file) => {
const p = join(BASE, lang, file)
if (!existsSync(p)) return null
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return null }
}
const files = readdirSync(join(BASE, 'en')).filter((f) => f.endsWith('.json')).sort()
const findings = []
const allowedHits = []
let compared = 0
for (const file of files) {
const en = read('en', file)
if (!en) continue
for (const [key, enVal] of Object.entries(en)) {
if (!enVal.trim()) continue
const values = []
for (const l of langs) {
const cat = read(l, file)
if (!cat) continue
const v = cat[key]
if (v !== undefined) values.push(v)
}
if (values.length < MIN_LOCALES) continue
compared++
if (new Set(values).size !== 1) continue
const id = `${file}:${key}`
if (ALLOWED.has(id)) { allowedHits.push({ id, why: ALLOWED.get(id), value: enVal }); continue }
findings.push({ file, key, value: enVal, locales: values.length })
}
}
console.log('I18N-NEVER-TRANSLATED')
console.log(` ${langs.length} locale(s), ${files.length} catalog(s), ${compared} key(s) compared`)
for (const a of allowedHits) {
console.log(` · allowed: ${a.id} = "${a.value.slice(0, 40)}"`)
console.log(` ${a.why}`)
}
if (findings.length) {
console.error(`\nI18N-NEVER-TRANSLATED: FAIL — ${findings.length} key(s) identical in every locale.`)
for (const f of findings) {
console.error(` ✗ ${f.file}:${f.key} = "${f.value.slice(0, 50)}" (same in all ${f.locales})`)
}
console.error('\n check:i18n-parity asserts these keys are PRESENT everywhere, and they are —')
console.error(' presence and translatedness are different properties. A reader in Arabic or')
console.error(' Chinese meets an English string sitting inside an otherwise localized surface.')
console.error('\n If a key is CORRECTLY the same everywhere (a pure format string, a brand name,')
console.error(' an ISO designation), add it to ALLOWED in this file WITH A REASON. Do not invent')
console.error(' translations to silence it: a wrong term looks authoritative to a reader who')
console.error(' cannot check it, which is worse than an honest English acronym.')
process.exit(1)
}
// ★ "ALLOWED" IS NOT ONE THING, and collapsing the two would make this gate lie. A proper noun
// is SETTLED — it is correct English forever. A DRIVER-GATED key is an OPEN DEFECT parked so the
// gate can still catch the next regression; calling it "identical on purpose" would turn a
// deferral into a verdict, which is exactly how an open item stops being visible.
const deferred = allowedHits.filter((a) => /DRIVER-GATED/.test(a.why))
if (deferred.length) {
console.log(`\n ⚠ ${deferred.length} DEFERRED, NOT SETTLED — these ARE untranslated and are waiting on a ruling:`)
for (const a of deferred) console.log(` ${a.id} = "${a.value.slice(0, 40)}"`)
}
console.log('\nI18N-NEVER-TRANSLATED: PASS — every compared key differs across locales, or is '
+ `declared identical on purpose${deferred.length ? `; ${deferred.length} deferred pending a ruling` : ''}.`)