137 lines
8.1 KiB
JavaScript
137 lines
8.1 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-i18n-keys-exist.mjs — a localized CALL SITE is not a localized STRING.
|
|
*
|
|
* THE FAILURE CLASS, found twice in one day and invisible both times.
|
|
*
|
|
* 1. The research dashboard's five resize handles read their accessible names through
|
|
* `T('aria.resizeFiltersRail', 'Resize filters rail')`. Reviewed, they look correct: the call
|
|
* goes to the catalogue, the fallback is only a fallback. But `aria.json` held ELEVEN keys and
|
|
* not one of them was a resize key — so every handle had been announcing itself in English in
|
|
* all eleven locales. Nothing was watching the JOIN between the call and the catalogue.
|
|
* 2. big-picture's footer reads `tStatic('dashboard.bp_subtitle') || "Search and filter the
|
|
* world's people groups · least-reached shown first"`. Same shape, same silence.
|
|
*
|
|
* ★ WHY NO EXISTING GATE SEES IT. check:i18n-parity asks whether every locale has the same keys as
|
|
* English — it does, because the key is missing from ALL of them equally. check:english-leaks and
|
|
* check:composite-locale-leaks both need the catalogue to HOLD the string before they can call its
|
|
* appearance a leak, and it does not. check:ui-strings-have-keys reads TEMPLATES for typed English.
|
|
* Every one of them is looking at a different end of the same wire. This asks the one question none
|
|
* of them asks: does the key this code names actually exist?
|
|
*
|
|
* THE FALLBACK IS WHAT MAKES IT SILENT, and the fallback is right to exist — a missing key must
|
|
* degrade to readable English, not to "dashboard.bp_subtitle" on screen. So the defect can only be
|
|
* seen by reading the two sides together, which is a gate's job and not a reviewer's.
|
|
*
|
|
* WHAT IT CHECKS. Every `tStatic('a.b')` / `T('a.b', …)` / `L('x', …)`-style literal key in
|
|
* plugin-designs/ and library/ resolves to a string in library/i18n/locales/en/. English is the
|
|
* reference because check:i18n-parity already guarantees the other ten match it — so one missing
|
|
* English key is eleven missing strings, and a key present in en is present everywhere.
|
|
*
|
|
* DYNAMIC KEYS ARE NOT GUESSED AT. `tStatic('dashboard.' + k)` and template literals are reported
|
|
* as UNRESOLVED and counted, never failed: this gate refuses to invent the key set a runtime
|
|
* expression might produce. A count that a reader can see is honest; a silent skip is not.
|
|
*
|
|
* NEGATIVE-TESTED 2026-09-26: deleting `aria.resetAllFilters` from en/aria.json makes it FAIL
|
|
* naming the key and the call site; restoring it returns PASS. It FAILS on today's tree.
|
|
*/
|
|
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
import { resolve, dirname, join, relative } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const TAG = 'I18N-KEYS-EXIST'
|
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
|
|
const EN = join(ROOT, 'library/i18n/locales/en')
|
|
if (!existsSync(EN)) { console.log(`${TAG}: SKIP — no English catalogue at ${relative(ROOT, EN)}.`); process.exit(0) }
|
|
|
|
// ── the English catalogue, as one flat key set: "<area>.<path.to.key>" ───────────────────────
|
|
// The area is the FILE NAME, because that is how every call site spells it: tStatic('aria.foo')
|
|
// reads aria.json. Nested objects flatten with dots, the way tStatic walks them.
|
|
const KEYS = new Set()
|
|
for (const f of readdirSync(EN)) {
|
|
if (!f.endsWith('.json')) continue
|
|
const area = f.replace(/\.json$/, '')
|
|
const walk = (node, path) => {
|
|
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
|
for (const [k, v] of Object.entries(node)) walk(v, path ? path + '.' + k : k)
|
|
} else if (typeof node === 'string') {
|
|
KEYS.add(area + '.' + path)
|
|
}
|
|
}
|
|
try { walk(JSON.parse(readFileSync(join(EN, f), 'utf8')), '') } catch (e) {
|
|
console.error(`${TAG}: ${f} does not parse — ${e.message}`); process.exit(1)
|
|
}
|
|
}
|
|
|
|
// ── every source file that could name a key ──────────────────────────────────────────────────
|
|
const SCAN = ['plugin-designs', 'library']
|
|
const files = []
|
|
// `scripts/` is build and gate TOOLING, not product UI. Skipping it is not a convenience: this
|
|
// gate's own header quotes two real call sites as examples, and scanning itself reported them as
|
|
// missing keys — a check that fails on its own documentation is a check nobody trusts.
|
|
const walkDir = (d) => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (e.name === 'node_modules' || e.name === 'locales' || e.name === 'scripts' || e.name.startsWith('.')) continue
|
|
const p = join(d, e.name)
|
|
if (e.isDirectory()) walkDir(p)
|
|
else if (/\.(vue|js|mjs)$/.test(e.name)) files.push(p)
|
|
}
|
|
}
|
|
for (const s of SCAN) { const p = join(ROOT, s); if (existsSync(p) && statSync(p).isDirectory()) walkDir(p) }
|
|
|
|
// A literal key: the first argument is a single-quoted FULL dotted key.
|
|
// ★ `T(` IS IN THE LIST, AND LEAVING IT OUT WOULD HAVE MADE THIS GATE USELESS ON THE VERY DEFECT
|
|
// THAT PROMPTED IT. The house pattern in this repo is a file-local alias —
|
|
// `const T = (key, en) => tStatic(key) || en` — so almost every call site in the dashboard reads
|
|
// `T('aria.resizeFiltersRail', …)`, not `tStatic(...)`. A first draft matched only tStatic/$t/t and
|
|
// reported four missing keys while sailing past the ten resize keys it was written for. The alias
|
|
// takes a full key, exactly as tStatic does, so it belongs here.
|
|
const LITERAL = /(?:^|[^\w$.])(?:tStatic|\$t|t|T)\(\s*'([a-zA-Z_][\w]*(?:\.[\w-]+)+)'/g
|
|
// A DYNAMIC key: the first argument is a concatenation or a template literal.
|
|
// ★ `L(` IS DELIBERATELY NOT RESOLVED. BpResearchHeader declares
|
|
// `const L = (key, en) => tStatic('grid.' + key) || tStatic('dashboard.' + key) || en`, so its
|
|
// first argument is a BARE key with a file-local prefix that this gate would have to guess at —
|
|
// and guessing is how a gate starts reporting keys nobody wrote. Counted as unresolved, like any
|
|
// other runtime expression.
|
|
const DYNAMIC = /(?:^|[^\w$.])(?:tStatic|\$t|t|T|L)\(\s*(?:'[^']*'\s*\+|`|'[a-z_]+'\s*,)/g
|
|
|
|
const missing = new Map() // key -> [call sites]
|
|
let literalCount = 0
|
|
let dynamicCount = 0
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8')
|
|
const rel = relative(ROOT, f)
|
|
for (const m of src.matchAll(LITERAL)) {
|
|
const key = m[1]
|
|
literalCount++
|
|
if (KEYS.has(key)) continue
|
|
// Only keys whose AREA is a real catalogue file are this gate's business: `props.t.total`,
|
|
// `config.map.provider` and friends are not i18n keys and must not be reported as missing.
|
|
const area = key.split('.')[0]
|
|
if (!existsSync(join(EN, area + '.json'))) continue
|
|
const line = src.slice(0, m.index).split('\n').length
|
|
if (!missing.has(key)) missing.set(key, [])
|
|
missing.get(key).push(`${rel}:${line}`)
|
|
}
|
|
for (const _ of src.matchAll(DYNAMIC)) dynamicCount++
|
|
}
|
|
|
|
console.log(` · ${KEYS.size} English keys across ${readdirSync(EN).filter((f) => f.endsWith('.json')).length} catalogue(s); ${literalCount} literal call site(s) in ${files.length} file(s)`)
|
|
if (dynamicCount) {
|
|
console.log(` · ${dynamicCount} DYNAMIC key expression(s) not checked — this gate will not invent the key set a runtime expression might produce.`)
|
|
}
|
|
|
|
if (missing.size) {
|
|
console.error(`\n${TAG}: FAIL — ${missing.size} key(s) are named in code and exist in NO catalogue, so every locale renders the English fallback:`)
|
|
for (const [key, sites] of [...missing].sort()) {
|
|
console.error(` ✗ ${key}`)
|
|
for (const s of sites.slice(0, 4)) console.error(` ${s}`)
|
|
if (sites.length > 4) console.error(` … and ${sites.length - 4} more`)
|
|
}
|
|
console.error('\n The call sites are RIGHT; the catalogue never caught up. Add the key to')
|
|
console.error(' library/i18n/locales/*/<area>.json in all eleven locales, keeping the English')
|
|
console.error(' wording exactly as the fallback reads it — translating existing wording is not a')
|
|
console.error(' product decision, but changing it is.')
|
|
process.exit(1)
|
|
}
|
|
console.log(`${TAG}: PASS — every literal catalogue key named in code exists in English.`)
|