check-locale-keys.mjs

Translation key checks

Tier 3designengineeringBlocks the commit

A t('key') with no entry in the base locale. A key in the base locale missing from a translation. An incomplete plural set.

Add to your project
Add "Translation key checks" from ntent to this repo.

Fetch https://ntent.app/r/f/check-locale-keys as plain text. Write it verbatim to scripts/check-locale-keys.mjs.

It needs more than one language, or the certainty you will have one. If this repo does not have it, stop and tell me.

Then check it: Add a t() call with a new key and no entry. The commit is refused. Before messages/ exists it says there is nothing to check yet and lets the commit through, so taking the locales capability on day one costs nothing.

Reads https://ntent.app/r/f/check-locale-keys

Read the code
check-locale-keys.mjsscripts/check-locale-keys.mjs
226 lines
#!/usr/bin/env node
/**
 * i18n gate: every translation key a component asks for actually exists, and
 * the locale files stay in step with each other.
 *
 * Why this needs its own script. TypeScript cannot catch it, because t() takes
 * any string. ESLint cannot catch it, because t('nav.settings') is a perfectly
 * well formed call. And a missing key does not crash: i18next renders the key
 * itself, so the screen shows "nav.settings" where a label should be. If the
 * key was on an aria-label, nothing shows at all and a screen reader reads the
 * dotted path aloud. Nobody catches that in a screenshot review.
 *
 * Four checks:
 *   1. USED-BUT-UNDEFINED  a literal t('key') with no entry in the base locale.
 *   2. MISSING-IN-<locale> a key in the base locale absent from another locale.
 *   3. BAD-PLURAL         a plural set without at least _one and _other.
 *   4. Unused keys, reported as info only, never a failure.
 *
 * On 4: some call sites build keys at runtime, t(`errors.${code}.title`), so a
 * key with no literal call site is not proof of a dead string. Failing on it
 * would train people to delete keys that are in use.
 *
 * Config lives in the CONFIG block below. No dependencies.
 *
 * Usage:
 *   node scripts/check-locale-keys.mjs           report everything, exit 0
 *   node scripts/check-locale-keys.mjs --check   exit 1 on any error
 *
 * Exit 2 means there was nothing to check: no locale directory yet. A repo
 * that took the locales capability on day one has this checker before it has
 * a second language, and "refuse every commit until messages/en.json exists"
 * is how a checker gets deleted in week one. The hook prints that as a note.
 */
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { join, extname, relative } from 'node:path'

// ---------------------------------------------------------------- CONFIG ---

const CONFIG = {
  // Where the locale JSON files live, one file per language.
  localeDir: 'messages',
  // The language everything else is compared against.
  baseLocale: 'en',
  // Directories scanned for t() calls.
  sourceDirs: ['src'],
  // File types scanned.
  sourceExts: ['.ts', '.tsx', '.js', '.jsx'],
  // Directory names never scanned.
  ignoreDirs: ['node_modules', '.next', 'dist', 'build', '.git', 'coverage'],
  // Languages allowed to hold keys the base locale does not have. Arabic and
  // Polish have plural categories English lacks, so extra keys there are
  // correct. The reverse, a key missing from a translation, is always a bug.
  allowExtraKeysIn: ['ar', 'pl', 'ru', 'cs'],
}

// ----------------------------------------------------------------- REGEX ---

// t('a.b.c') or t("a.b.c"). Literal first argument only: a template literal is
// dynamic and deliberately out of scope.
const T_CALL = /\bt\(\s*['"]([A-Za-z0-9_.-]+)['"]/g

// A key passed to a component as a prop rather than resolved at the call site:
// <Banner titleKey="offline.title" />. The component calls t() on it, so the
// literal never appears inside a t() and the scanner above cannot see it. Every
// *Key prop in a codebase like this is a locale key, so the shape is enough.
const KEY_PROP = /\b[a-zA-Z]+Key\s*=\s*['"]([A-Za-z0-9_.-]+)['"]/g

// i18next plural and context suffixes (CLDR categories).
const PLURAL_SUFFIX = /_(zero|one|two|few|many|other)$/

// --------------------------------------------------------------- HELPERS ---

/** Flatten { a: { b: 1 } } to { 'a.b': 1 } so keys compare as dotted paths. */
function flatten(node, prefix = '', out = {}) {
  for (const [key, value] of Object.entries(node)) {
    const path = prefix ? `${prefix}.${key}` : key
    if (value && typeof value === 'object' && !Array.isArray(value)) flatten(value, path, out)
    else out[path] = value
  }
  return out
}

function walkFiles(dir, acc = []) {
  let entries
  try {
    entries = readdirSync(dir)
  } catch {
    return acc
  }
  for (const entry of entries) {
    if (CONFIG.ignoreDirs.includes(entry)) continue
    const full = join(dir, entry)
    if (statSync(full).isDirectory()) walkFiles(full, acc)
    else if (CONFIG.sourceExts.includes(extname(full))) acc.push(full)
  }
  return acc
}

/** Every key the file asks for, with the line it asks on. */
function keysUsedIn(file) {
  const text = readFileSync(file, 'utf8')
  const lines = text.split('\n')
  const found = []
  lines.forEach((line, i) => {
    for (const re of [T_CALL, KEY_PROP]) {
      re.lastIndex = 0
      let m
      while ((m = re.exec(line))) found.push({ key: m[1], file, line: i + 1 })
    }
  })
  return found
}

/**
 * A key resolves if it is present, or if it is a plural base whose variants are
 * present. t('items') with items_one and items_other defined is correct.
 */
function resolves(key, defined) {
  if (defined.has(key)) return true
  return [...defined].some((d) => d.startsWith(`${key}_`) && PLURAL_SUFFIX.test(d))
}

function loadLocales() {
  const files = readdirSync(CONFIG.localeDir).filter((f) => f.endsWith('.json'))
  const locales = {}
  for (const file of files) {
    const name = file.replace(/\.json$/, '')
    locales[name] = flatten(JSON.parse(readFileSync(join(CONFIG.localeDir, file), 'utf8')))
  }
  return locales
}

// ------------------------------------------------------------------ MAIN ---

function main() {
  const strict = process.argv.includes('--check')
  if (!existsSync(CONFIG.localeDir)) {
    console.error(`No locale directory at ${CONFIG.localeDir}/. Nothing to check yet.`)
    console.error(`  fix: create ${CONFIG.localeDir}/${CONFIG.baseLocale}.json when the first key exists, or`)
    console.error(`       point CONFIG.localeDir at where the locale files live.`)
    process.exit(2)
  }
  const locales = loadLocales()
  const base = locales[CONFIG.baseLocale]

  if (!base) {
    console.error(`No base locale at ${CONFIG.localeDir}/${CONFIG.baseLocale}.json`)
    process.exit(1)
  }

  const baseKeys = new Set(Object.keys(base))
  const errors = []
  const info = []

  // 1. Used but undefined.
  const files = CONFIG.sourceDirs.flatMap((d) => walkFiles(d))
  const used = new Map()
  for (const file of files) {
    for (const hit of keysUsedIn(file)) {
      if (!used.has(hit.key)) used.set(hit.key, [])
      used.get(hit.key).push(hit)
    }
  }
  for (const [key, hits] of used) {
    if (!resolves(key, baseKeys)) {
      const where = hits.map((h) => `${relative(process.cwd(), h.file)}:${h.line}`).join(', ')
      errors.push(`USED-BUT-UNDEFINED  ${key}\n    called at ${where}\n    fix: add it to ${CONFIG.localeDir}/${CONFIG.baseLocale}.json and every other locale`)
    }
  }

  // 2. Missing in a translation.
  for (const [name, keys] of Object.entries(locales)) {
    if (name === CONFIG.baseLocale) continue
    const theirs = new Set(Object.keys(keys))
    const missing = [...baseKeys].filter((k) => !theirs.has(k))
    for (const key of missing) {
      errors.push(`MISSING-IN-${name.toUpperCase()}  ${key}\n    fix: add it to ${CONFIG.localeDir}/${name}.json`)
    }
    if (!CONFIG.allowExtraKeysIn.includes(name)) {
      const extra = [...theirs].filter((k) => !baseKeys.has(k))
      for (const key of extra) {
        errors.push(`EXTRA-IN-${name.toUpperCase()}  ${key}\n    fix: remove it, or add it to the base locale if it is real`)
      }
    }
  }

  // 3. Incomplete plural sets in the base locale.
  const pluralBases = new Set()
  for (const key of baseKeys) {
    const m = key.match(PLURAL_SUFFIX)
    if (m) pluralBases.add(key.slice(0, -m[0].length))
  }
  for (const stem of pluralBases) {
    const has = (suffix) => baseKeys.has(`${stem}_${suffix}`)
    if (!has('one') || !has('other')) {
      errors.push(`BAD-PLURAL  ${stem}\n    fix: an English plural set needs at least ${stem}_one and ${stem}_other`)
    }
  }

  // 4. Unused, info only.
  for (const key of baseKeys) {
    const stem = key.replace(PLURAL_SUFFIX, '')
    if (!used.has(key) && !used.has(stem)) info.push(key)
  }

  // -------------------------------------------------------------- REPORT ---

  if (errors.length) {
    console.log(`\ni18n: ${errors.length} problem(s)\n`)
    for (const e of errors) console.log(`  ${e}\n`)
  } else {
    console.log(`i18n: ok. ${baseKeys.size} keys, ${Object.keys(locales).length} locales, ${files.length} files scanned.`)
  }

  if (info.length && !strict) {
    console.log(`\n  info: ${info.length} key(s) with no literal call site. Not a failure:`)
    console.log(`  some call sites build keys at runtime. Check by hand before deleting.`)
    for (const k of info.slice(0, 20)) console.log(`    ${k}`)
    if (info.length > 20) console.log(`    ... and ${info.length - 20} more`)
  }

  process.exit(strict && errors.length ? 1 : 0)
}

main()

Success check: Add a t() call with a new key and no entry. The commit is refused. Before messages/ exists it says there is nothing to check yet and lets the commit through, so taking the locales capability on day one costs nothing.

Included in