Spaces:
Sleeping
Sleeping
| // i18n parity check — keeps Julien's "every locale = same files + keys as en" | |
| // DoD honest over time. | |
| // | |
| // What it checks per non-en locale: | |
| // 1. File set parity: every domain file that exists in en/ must exist in this | |
| // locale's dir; no extra domain files allowed. | |
| // 2. Key set parity: for each shared domain file, the top-level translation | |
| // keys must match exactly (no missing, no extra). | |
| // | |
| // Output: structured report grouped by locale, plus a `--strict` flag that | |
| // returns exit-code 1 when any drift is present (intended for CI). Without | |
| // `--strict` the script exits 0 and prints, so it can also run as a non-blocking | |
| // audit during translation work. | |
| // | |
| // Limitations: we only parse *top-level* string keys (those declared as the | |
| // first column of the file, matching the regex below). Nested objects, function | |
| // bodies, and inline comments are ignored. This matches how `t(key)` calls | |
| // resolve at runtime in TranslationContext. | |
| import { readdirSync, readFileSync, statSync } from 'node:fs'; | |
| import { dirname, join } from 'node:path'; | |
| import { fileURLToPath } from 'node:url'; | |
| const here = dirname(fileURLToPath(import.meta.url)); | |
| const i18nRoot = join(here, '..', 'src', 'i18n'); | |
| // Match a top-level translation key declaration: leading whitespace, then a | |
| // quoted key (must start with a lowercase letter), then a colon. This is the | |
| // exact pattern every domain file uses. | |
| const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_]*)'\s*:/gm; | |
| function listLocales() { | |
| return readdirSync(i18nRoot) | |
| .filter((name) => statSync(join(i18nRoot, name)).isDirectory()) | |
| // externalNotifications is a barrel module, not a locale. | |
| .filter((name) => name !== 'externalNotifications'); | |
| } | |
| function listDomainFiles(locale) { | |
| return readdirSync(join(i18nRoot, locale)) | |
| .filter((f) => f.endsWith('.ts') && f !== 'index.ts') | |
| .sort(); | |
| } | |
| function extractKeys(locale, file) { | |
| const content = readFileSync(join(i18nRoot, locale, file), 'utf8'); | |
| const keys = new Set(); | |
| for (const match of content.matchAll(TOP_LEVEL_KEY_RE)) { | |
| keys.add(match[1]); | |
| } | |
| return keys; | |
| } | |
| function diffSets(reference, candidate) { | |
| const missing = []; | |
| const extra = []; | |
| for (const k of reference) if (!candidate.has(k)) missing.push(k); | |
| for (const k of candidate) if (!reference.has(k)) extra.push(k); | |
| return { missing, extra }; | |
| } | |
| function checkParity() { | |
| const locales = listLocales(); | |
| if (!locales.includes('en')) { | |
| throw new Error('shared/src/i18n/en is required as the reference locale'); | |
| } | |
| const enFiles = listDomainFiles('en'); | |
| const enKeysByDomain = new Map(); | |
| for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f)); | |
| const report = { fileDrift: [], keyDrift: [] }; | |
| for (const locale of locales) { | |
| if (locale === 'en') continue; | |
| const localeFiles = listDomainFiles(locale); | |
| const { missing: missingFiles, extra: extraFiles } = diffSets( | |
| new Set(enFiles), | |
| new Set(localeFiles), | |
| ); | |
| if (missingFiles.length || extraFiles.length) { | |
| report.fileDrift.push({ locale, missing: missingFiles, extra: extraFiles }); | |
| } | |
| for (const file of enFiles) { | |
| if (!localeFiles.includes(file)) continue; | |
| const localeKeys = extractKeys(locale, file); | |
| const { missing, extra } = diffSets(enKeysByDomain.get(file), localeKeys); | |
| if (missing.length || extra.length) { | |
| report.keyDrift.push({ locale, file, missing, extra }); | |
| } | |
| } | |
| } | |
| return report; | |
| } | |
| function formatReport(report) { | |
| const lines = []; | |
| if (report.fileDrift.length === 0) { | |
| lines.push('File parity: OK'); | |
| } else { | |
| lines.push(`File parity: ${report.fileDrift.length} locale(s) with file drift`); | |
| for (const { locale, missing, extra } of report.fileDrift) { | |
| if (missing.length) lines.push(` ${locale}: missing ${missing.join(', ')}`); | |
| if (extra.length) lines.push(` ${locale}: extra ${extra.join(', ')}`); | |
| } | |
| } | |
| if (report.keyDrift.length === 0) { | |
| lines.push('Key parity: OK'); | |
| } else { | |
| lines.push(`Key parity: ${report.keyDrift.length} domain file(s) with key drift`); | |
| for (const { locale, file, missing, extra } of report.keyDrift) { | |
| const parts = []; | |
| if (missing.length) parts.push(`missing ${missing.length} (e.g. ${missing.slice(0, 3).join(', ')})`); | |
| if (extra.length) parts.push(`extra ${extra.length} (e.g. ${extra.slice(0, 3).join(', ')})`); | |
| lines.push(` ${locale}/${file}: ${parts.join('; ')}`); | |
| } | |
| } | |
| return lines.join('\n'); | |
| } | |
| // Export a structured API for vitest. The CLI entry point only runs when | |
| // executed directly (`node scripts/i18n-parity.mjs`), so importing this file | |
| // from a spec does not produce side effects. | |
| export { checkParity, formatReport }; | |
| const isCli = process.argv[1] && process.argv[1].endsWith('i18n-parity.mjs'); | |
| if (isCli) { | |
| const strict = process.argv.includes('--strict'); | |
| const filesOnly = process.argv.includes('--files-only'); | |
| const report = checkParity(); | |
| process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [] } : report) + '\n'); | |
| if (strict) { | |
| const hasFileDrift = report.fileDrift.length > 0; | |
| const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0; | |
| if (hasFileDrift || hasKeyDrift) process.exit(1); | |
| } | |
| } | |