Files
donetick/scripts/i18n-audit.mjs

138 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Fails if any t() / <Trans> key in src/ has no entry in public/locales/en/.
*
* Why this is a script and not an ESLint rule: the namespace for a bare
* t('foo') comes from the useTranslation('chores') call at the top of the
* component, so resolving a key needs whole-file context that a per-node lint
* rule does not have. Editor i18n plugins get this wrong constantly — they
* assume the default namespace and flag half the codebase.
*
* Usage: node scripts/i18n-audit.mjs [--orphans]
* --orphans also list en/*.json keys that nothing in src/ references
*/
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { basename, join } from 'node:path'
const LOCALE_DIR = 'public/locales/en'
const SRC_DIR = 'src'
const DEFAULT_NS = 'common'
const bundles = Object.fromEntries(
readdirSync(LOCALE_DIR)
.filter(f => f.endsWith('.json'))
.map(f => [
basename(f, '.json'),
JSON.parse(readFileSync(join(LOCALE_DIR, f), 'utf8')),
]),
)
const walk = dir =>
readdirSync(dir).flatMap(entry => {
const full = join(dir, entry)
return statSync(full).isDirectory()
? walk(full)
: /\.jsx?$/.test(full)
? [full]
: []
})
const lookup = (ns, key) =>
key.split('.').reduce((cur, part) => {
if (cur === null || typeof cur !== 'object' || !(part in cur)) return null
return cur[part]
}, bundles[ns] ?? null)
/**
* i18next appends _one/_other to plural keys, so `card.points` is present in
* the bundle as `card.points_one` + `card.points_other`. Treat a leaf whose
* siblings carry a plural suffix as resolved.
*/
const exists = (ns, key) => {
if (lookup(ns, key) !== null) return true
const dot = key.lastIndexOf('.')
const parent =
dot === -1 ? (bundles[ns] ?? null) : lookup(ns, key.slice(0, dot))
const leaf = key.slice(dot + 1)
return (
parent !== null &&
typeof parent === 'object' &&
Object.keys(parent).some(k => k.startsWith(`${leaf}_`))
)
}
const collectKeys = src => {
const found = new Set()
const push = re => {
for (const m of src.matchAll(re)) found.add(m[1])
}
// t('key') / t("key") / t(`key`)
push(/\bt\(\s*'([A-Za-z][\w.:-]*)'/g)
push(/\bt\(\s*"([A-Za-z][\w.:-]*)"/g)
push(/\bt\(\s*`([A-Za-z][\w.:-]*)`/g)
// t(`prefix.${expr}`) — the interpolated leaf is unknowable, but the
// prefix must at least resolve to an object
push(/\bt\(\s*`([A-Za-z][\w.:-]*)\.\$\{/g)
return found
}
const failures = []
for (const file of walk(SRC_DIR).sort()) {
const src = readFileSync(file, 'utf8')
const nsMatch = src.match(/useTranslation\(\s*(?:'([^']+)'|\[\s*'([^']+)')/)
const hasTrans = src.includes('i18nKey')
if (!nsMatch && !hasTrans) continue
const fallbackNs = nsMatch ? (nsMatch[1] ?? nsMatch[2]) : DEFAULT_NS
const check = (raw, kind, nsOverride) => {
const [ns, key] = raw.includes(':')
? raw.split(/:(.+)/)
: [nsOverride ?? fallbackNs, raw]
if (!exists(ns, key)) failures.push(`${file} ${kind} ${ns}:${key}`)
}
for (const raw of collectKeys(src)) check(raw, 't()')
// <Trans i18nKey='x' ns='y'> and thin wrappers that forward i18nKey
const transNs = src.match(/\bns='([^']+)'/)?.[1]
for (const m of src.matchAll(/i18nKey=(?:'([^']+)'|\{'([^']+)'\})/g)) {
check(m[1] ?? m[2], 'Trans', transNs)
}
}
if (process.argv.includes('--orphans')) {
const allSrc = walk(SRC_DIR)
.map(f => readFileSync(f, 'utf8'))
.join('\n')
const flatten = (obj, prefix = '') =>
Object.entries(obj).flatMap(([k, v]) =>
v !== null && typeof v === 'object'
? flatten(v, `${prefix}${k}.`)
: [`${prefix}${k}`],
)
const orphans = []
for (const [ns, bundle] of Object.entries(bundles)) {
for (const key of flatten(bundle)) {
const leaf = key.replace(/_(one|other|zero|two|few|many)$/, '')
if (!allSrc.includes(leaf) && !allSrc.includes(leaf.split('.').pop())) {
orphans.push(`${ns}:${leaf}`)
}
}
}
if (orphans.length) {
console.log(`\nPossibly unused (${new Set(orphans).size}):`)
console.log([...new Set(orphans)].sort().join('\n'))
}
}
const unique = [...new Set(failures)].sort()
if (unique.length) {
console.error(`i18n audit: ${unique.length} unresolved key(s)\n`)
console.error(unique.join('\n'))
process.exit(1)
}
console.log('i18n audit: all keys resolve')