120 lines
5.8 KiB
JavaScript
120 lines
5.8 KiB
JavaScript
// check-i18n-parity — a key that exists in English must exist in every locale.
|
|
//
|
|
// ★ THE FAILURE IS SILENT BY DESIGN. vue-i18n falls back to English for a missing
|
|
// key, which is the right runtime behaviour and the reason this drifts unnoticed:
|
|
// the app looks fine, nothing errors, and a reader who chose Spanish simply gets
|
|
// English words with no indication anything is missing.
|
|
//
|
|
// Measured when this was written: buttons.json had 21 keys in English and 19 in
|
|
// every other locale. The two orphans were `share` and `shareSteps` — the Share
|
|
// button's own title and its five-step explainer, rendered on EVERY map. Ten
|
|
// locales had been shipping an English Share button since the key was added.
|
|
//
|
|
// It reports per-locale so a partial translation is visible as a number rather
|
|
// than as a vague sense that "some things aren't translated".
|
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
import { resolve, join, dirname } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
// ⚠ REPO-RELATIVE, never an absolute path. This gate exists in MORE THAN ONE repo
|
|
// (doxa-maps-plugin-studio and wt-packet are separate repos, not worktrees), and a
|
|
// hardcoded /home/... made the studio copy silently grade wt-packet's locales —
|
|
// so a ceiling lowered in one repo left the other's stale and neither was wrong on
|
|
// its face. Each copy now checks ITS OWN tree. (check:no-abs-paths, 2026-09-24.)
|
|
const REPO = process.env.DOXA_REPO
|
|
|| resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
const ROOT = join(REPO, 'library/i18n/locales')
|
|
if (!existsSync(ROOT)) { console.error(`I18N-PARITY: FAIL — no locales at ${ROOT}`); process.exit(1) }
|
|
|
|
// Flatten so nested objects and arrays are compared key-by-key, not shallowly.
|
|
const flat = (obj, prefix = '', out = {}) => {
|
|
for (const [k, v] of Object.entries(obj || {})) {
|
|
const key = prefix ? `${prefix}.${k}` : k
|
|
if (v && typeof v === 'object' && !Array.isArray(v)) flat(v, key, out)
|
|
else out[key] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
const read = (loc, area) => {
|
|
const p = join(ROOT, loc, area)
|
|
if (!existsSync(p)) return null
|
|
try { return flat(JSON.parse(readFileSync(p, 'utf8'))) } catch { return undefined }
|
|
}
|
|
|
|
const locales = readdirSync(ROOT).filter((d) => existsSync(join(ROOT, d)))
|
|
if (!locales.includes('en')) { console.error('I18N-PARITY: FAIL — no English catalog to compare against'); process.exit(1) }
|
|
const areas = readdirSync(join(ROOT, 'en')).filter((f) => f.endsWith('.json'))
|
|
|
|
// ── KNOWN DEBT, RATCHETED — AND THE MAP IS NOW EMPTY ────────────────────────
|
|
// ★ ALL ELEVEN LOCALES ARE COMPLETE. 2026-09-24: the last 162 keys closed
|
|
// (regions.json for it/ro/zh, 54 each), finishing the 429-key debt this gate
|
|
// found when it was written.
|
|
//
|
|
// THE MAP IS DELIBERATELY EMPTY RATHER THAN FULL OF ZEROS. A locale with no entry
|
|
// here MUST be complete — the gate fails it outright for a single missing key —
|
|
// so an empty DEBT is the strongest state this ratchet can be in, not an unset
|
|
// one. Writing `{ it: 0, ... }` would mean the same today and quietly permit a
|
|
// regression back up the moment someone "adjusted" a number.
|
|
//
|
|
// HOW IT WORKED, for whoever needs to add debt back: these were a CEILING, not a
|
|
// target — the gate failed when a locale got WORSE and printed a nudge when one
|
|
// improved, so the number could be lowered. That let real translation work sit in
|
|
// the backlog without blocking every build, which is what a gate everyone
|
|
// disables protects against. If a new English key lands ahead of its
|
|
// translations, add the locale here with its count and lower it as they arrive.
|
|
// Never raise a number; never add a locale that is already complete.
|
|
const DEBT = {
|
|
}
|
|
|
|
const fails = []
|
|
const improved = []
|
|
const rows = []
|
|
for (const loc of locales.filter((l) => l !== 'en').sort()) {
|
|
let missing = 0
|
|
let total = 0
|
|
const examples = []
|
|
for (const area of areas) {
|
|
const en = read('en', area)
|
|
const other = read(loc, area)
|
|
total += Object.keys(en).length
|
|
if (other === null) { missing += Object.keys(en).length; examples.push(`${area} (whole file)`); continue }
|
|
if (other === undefined) { fails.push(`${loc}/${area} is not valid JSON`); continue }
|
|
for (const k of Object.keys(en)) {
|
|
if (!(k in other)) { missing += 1; if (examples.length < 3) examples.push(`${area}:${k}`) }
|
|
}
|
|
}
|
|
rows.push({ loc, missing, total, examples })
|
|
const ceiling = DEBT[loc]
|
|
if (ceiling === undefined) {
|
|
if (missing) fails.push(`${loc} is missing ${missing}/${total} key(s) and has no DEBT entry — `
|
|
+ `e.g. ${examples.join(', ')}`)
|
|
} else if (missing > ceiling) {
|
|
fails.push(`${loc} regressed: ${missing} missing key(s), was ${ceiling} — `
|
|
+ `e.g. ${examples.join(', ')}`)
|
|
} else if (missing < ceiling) {
|
|
improved.push(`${loc}: ${missing} missing, ceiling still says ${ceiling} — lower it`)
|
|
}
|
|
}
|
|
|
|
console.log(` locales : ${locales.length} (${locales.sort().join(' ')})`)
|
|
console.log(` areas : ${areas.length} catalog file(s), ${Object.keys(read('en', areas[0]) || {}).length}+ keys each`)
|
|
for (const r of rows) {
|
|
const c = DEBT[r.loc]
|
|
const tag = r.missing === 0 ? 'complete'
|
|
: (c !== undefined && r.missing <= c ? `${r.missing} missing (known debt, ceiling ${c})`
|
|
: `${r.missing} missing`)
|
|
console.log(` ${r.loc.padEnd(4)} ${tag}`)
|
|
}
|
|
|
|
if (fails.length) {
|
|
console.error(`\nI18N-PARITY: FAIL — ${fails.length} problem(s)`)
|
|
for (const f of fails) console.error(' - ' + f)
|
|
process.exit(1)
|
|
}
|
|
if (improved.length) {
|
|
console.log('\n ↓ improved since the ceiling was set — lower DEBT in this file:')
|
|
for (const i of improved) console.log(' ' + i)
|
|
}
|
|
const debt = rows.reduce((a, r) => a + r.missing, 0)
|
|
console.log(`\nI18N-PARITY: PASS — no locale regressed. ${debt} key(s) of known translation debt remain.`)
|