doxa-maps/plugin-designs/research-map-plus-grid/scripts/check-i18n-keys-exist.mjs
Doxa Maps 31f98c51ec
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Update from source 1d569edd
2026-09-26 17:45:31 +00:00

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.`)