PSA/scripts/validate-translations.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

271 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Validate that all locale translation files have the same keys as English.
*
* Checks:
* 1. Every non-English locale has the same files as English
* 2. Every file has identical key structure (nested paths)
* 3. No extra keys in non-English files
* 4. No missing keys in non-English files
* 5. All {{variables}} from English are preserved
* 6. Valid JSON syntax
*
* Exit code 0 = pass, 1 = failures found.
*
* Usage:
* node scripts/validate-translations.cjs
*/
const fs = require('fs');
const path = require('path');
const LOCALES_DIR = process.env.LOCALES_DIR
? path.resolve(process.env.LOCALES_DIR)
: path.resolve(__dirname, '../server/public/locales');
const REFERENCE_LOCALE = 'en';
const PSEUDO_LOCALES = ['xx', 'yy'];
const PSEUDO_FILLS = { xx: '11111', yy: '55555' };
let errorCount = 0;
let warnCount = 0;
function error(msg) {
console.error(` ERROR: ${msg}`);
errorCount++;
}
function warn(msg) {
console.warn(` WARN: ${msg}`);
warnCount++;
}
/**
* Recursively collect all leaf key paths from a nested object.
* Returns a Map of dotted-path -> value.
*/
function collectKeys(obj, prefix = '') {
const map = new Map();
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
for (const [k, v] of collectKeys(value, fullKey)) {
map.set(k, v);
}
} else {
map.set(fullKey, value);
}
}
return map;
}
/**
* Extract simple {{variable}} tokens from a string.
* Ignores i18next plural/formatting syntax like {{count, plural, ...}}.
*/
function extractVars(str) {
if (typeof str !== 'string') return [];
const matches = str.match(/\{\{[^}]*\}\}/g);
if (!matches) return [];
// Keep only simple variable tokens (no commas = no i18next formatting syntax)
return matches.filter((m) => !m.includes(',')).sort();
}
/**
* CLDR plural support. i18next v4 stores count-based keys as base_<category>.
* A locale must provide every category its plural rules produce for integer
* counts (0..100) — e.g. Polish needs one/few/many — plus 'other', the
* universal i18next fallback. Categories that only apply to huge numbers
* (e.g. French 'many' at 1e6) are intentionally not required.
*/
const PLURAL_SUFFIXES = ['zero', 'one', 'two', 'few', 'many', 'other'];
function pluralBase(key) {
const idx = key.lastIndexOf('_');
if (idx === -1) return null;
const suffix = key.slice(idx + 1);
return PLURAL_SUFFIXES.includes(suffix) ? { base: key.slice(0, idx), suffix } : null;
}
const categoriesCache = new Map();
function requiredCategories(locale) {
if (categoriesCache.has(locale)) return categoriesCache.get(locale);
let cats = new Set();
try {
const pr = new Intl.PluralRules(locale);
for (let i = 0; i <= 100; i++) cats.add(pr.select(i));
} catch {
cats = new Set(['one']);
}
cats.add('other');
categoriesCache.set(locale, cats);
return cats;
}
/**
* Recursively find all JSON files relative to a base directory.
*/
function walkJson(dir, base) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkJson(full, base));
} else if (entry.name.endsWith('.json')) {
results.push(path.relative(base, full));
}
}
return results.sort();
}
// --- Main ---
const enDir = path.join(LOCALES_DIR, REFERENCE_LOCALE);
if (!fs.existsSync(enDir)) {
console.error(`Reference locale directory not found: ${enDir}`);
process.exit(1);
}
const enFiles = walkJson(enDir, enDir);
console.log(`Reference locale (${REFERENCE_LOCALE}): ${enFiles.length} files\n`);
// Discover non-English, non-pseudo locales
const realLocales = fs.readdirSync(LOCALES_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
.filter((name) => name !== REFERENCE_LOCALE && !PSEUDO_LOCALES.includes(name))
.sort();
// Discover pseudo-locales that exist on disk
const presentPseudoLocales = PSEUDO_LOCALES.filter(
(l) => fs.existsSync(path.join(LOCALES_DIR, l))
);
const allLocales = [...realLocales, ...presentPseudoLocales];
console.log(`Checking locales: ${realLocales.join(', ')}`);
if (presentPseudoLocales.length) {
console.log(`Checking pseudo-locales: ${presentPseudoLocales.join(', ')} (key structure only)`);
}
console.log('');
for (const locale of allLocales) {
const isPseudo = PSEUDO_LOCALES.includes(locale);
console.log(`--- ${locale}${isPseudo ? ' (pseudo)' : ''} ---`);
const localeDir = path.join(LOCALES_DIR, locale);
const localeFiles = walkJson(localeDir, localeDir);
// Check for missing files
for (const file of enFiles) {
if (!localeFiles.includes(file)) {
error(`Missing file: ${file}`);
}
}
// Check for extra files
for (const file of localeFiles) {
if (!enFiles.includes(file)) {
warn(`Extra file not in English: ${file}`);
}
}
// Check each shared file
for (const file of enFiles) {
const localeFile = path.join(localeDir, file);
if (!fs.existsSync(localeFile)) continue;
// Validate JSON syntax
let localeData;
try {
localeData = JSON.parse(fs.readFileSync(localeFile, 'utf8'));
} catch (e) {
error(`${file}: Invalid JSON — ${e.message}`);
continue;
}
const enData = JSON.parse(fs.readFileSync(path.join(enDir, file), 'utf8'));
const enKeys = collectKeys(enData);
const localeKeys = collectKeys(localeData);
// Plural sets in English: base -> Map(suffix -> value)
const enPluralSets = new Map();
for (const [key, value] of enKeys) {
const p = pluralBase(key);
if (p) {
if (!enPluralSets.has(p.base)) enPluralSets.set(p.base, new Map());
enPluralSets.get(p.base).set(p.suffix, value);
}
}
// Legacy i18next v3 `_plural` keys never resolve under v4 plural mode.
for (const key of localeKeys.keys()) {
if (key.endsWith('_plural')) {
error(`${file}: Legacy "_plural" key "${key}" — migrate to CLDR suffixes (_one/_other/…)`);
}
}
// Missing keys (non-plural English keys compare 1:1)
for (const [key, enValue] of enKeys) {
const p = pluralBase(key);
if (p && enPluralSets.has(p.base)) continue; // handled per plural set below
if (!localeKeys.has(key)) {
error(`${file}: Missing key "${key}"`);
} else if (!isPseudo) {
// Check {{variables}} are preserved (skip for pseudo-locales)
const enVars = extractVars(enValue);
const localeVars = extractVars(localeKeys.get(key));
if (enVars.length > 0 && JSON.stringify(enVars) !== JSON.stringify(localeVars)) {
error(`${file}: Key "${key}" — variable mismatch. English: ${enVars.join(', ')} | ${locale}: ${localeVars.join(', ')}`);
}
}
}
// Plural sets: the locale must cover its own required CLDR categories.
const localeCats = requiredCategories(isPseudo ? REFERENCE_LOCALE : locale);
for (const [base, enForms] of enPluralSets) {
for (const cat of localeCats) {
if (!localeKeys.has(`${base}_${cat}`)) {
error(`${file}: Missing plural form "${base}_${cat}" (required for ${isPseudo ? REFERENCE_LOCALE : locale})`);
}
}
if (isPseudo) continue;
const enReference = enForms.get('other') ?? [...enForms.values()][0];
for (const suffix of PLURAL_SUFFIXES) {
const key = `${base}_${suffix}`;
if (!localeKeys.has(key)) continue;
const enVars = extractVars(enForms.get(suffix) ?? enReference);
const localeVars = extractVars(localeKeys.get(key));
if (enVars.length > 0 && JSON.stringify(enVars) !== JSON.stringify(localeVars)) {
error(`${file}: Key "${key}" — variable mismatch. English: ${enVars.join(', ')} | ${locale}: ${localeVars.join(', ')}`);
}
}
}
// Extra keys (locale-required plural categories are not "extra")
for (const key of localeKeys.keys()) {
if (enKeys.has(key)) continue;
const p = pluralBase(key);
if (p && enPluralSets.has(p.base)) continue;
warn(`${file}: Extra key "${key}" not in English`);
}
}
console.log('');
}
// Summary
console.log('=== Summary ===');
console.log(`Locales checked: ${allLocales.length}`);
console.log(`Errors: ${errorCount}`);
console.log(`Warnings: ${warnCount}`);
if (errorCount > 0) {
console.log('\nFAILED — fix errors above.');
process.exit(1);
} else {
console.log('\nPASSED');
process.exit(0);
}