The Driver's ask: one folder per BUNDLE with its ELEMENTS inside, bundles sorted by capability, highest first. The rail listed every element flat under its own capability, so a bundle's elements were scattered (gospel-resources' five screens sat in three sections). Now each capability section (5 → 1) holds the bundles whose HIGHEST capability it is, in name order; a bundle with several elements is one folder (country-posters, gospel-resources, my-upg-100-list, regions-dashboard, cities-internet), and a single-element bundle is shown as its element. Composites keep their folded composition trees. StudioTree identifies a plugin's row by a `canonical` flag rather than "depth 0", so auto-reveal opens the bundle folder above an element chosen by URL or restored tab. New folder icon; 'elementsCount' and 'elements' in all 11 studio catalogues. check:studio-rail (headless, gates:post) runs the studio's real rail code on the built manifest and asserts the layout and one canonical node per plugin (21 bundles, 33 elements); it fails on the previous rail. Also: poster.json:dpi is declared identical on purpose in check:i18n-never-translated (a unit symbol kept as-is by project decision), which had turned that gate red on main.
171 lines
9.7 KiB
JavaScript
171 lines
9.7 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'],
|
|
// ── UNITS ───────────────────────────────────────────────────────────────────
|
|
['poster.json:dpi',
|
|
'unit symbol — "DPI" is how print resolution is labelled in every one of these locales\' print dialogs; kept as-is by project decision (the poster-strings landing)'],
|
|
// ── 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 Driver 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` : ''}.`)
|