Market-Frontend/scripts/validate-i18n.mjs
Alexander Medvedev b280372eb7 fix: nav
2026-08-27 18:49:20 +02:00

159 lines
5.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const localesDir = path.join(rootDir, 'src', 'locales');
const srcDir = path.join(rootDir, 'src');
let hasErrors = false;
function logError(msg) {
console.error(`\x1b[31m✖ ${msg}\x1b[0m`);
hasErrors = true;
}
function logSuccess(msg) {
console.log(`\x1b[32m✔ ${msg}\x1b[0m`);
}
function logInfo(msg) {
console.log(`\x1b[36mℹ ${msg}\x1b[0m`);
}
// 1. Load and parse all locale files
logInfo('Validating locale JSON files...');
const localeFiles = fs.readdirSync(localesDir).filter(f => f.endsWith('.json'));
const locales = {};
for (const file of localeFiles) {
const lang = path.basename(file, '.json');
const filePath = path.join(localesDir, file);
try {
const content = fs.readFileSync(filePath, 'utf-8');
locales[lang] = JSON.parse(content);
} catch (err) {
logError(`Invalid JSON in ${file}: ${err.message}`);
}
}
if (!locales.en) {
logError('Reference locale en.json not found!');
process.exit(1);
}
// Helper to flatten nested object keys
function flattenKeys(obj, prefix = '') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
result[fullKey] = { type: 'object', value };
Object.assign(result, flattenKeys(value, fullKey));
} else {
result[fullKey] = { type: typeof value, value };
}
}
return result;
}
// Helper to get nested value by dot path
function getNestedValue(obj, keyPath) {
const parts = keyPath.split('.');
let current = obj;
for (const part of parts) {
if (current && typeof current === 'object' && part in current) {
current = current[part];
} else {
return undefined;
}
}
return current;
}
const enFlat = flattenKeys(locales.en);
const enLeafKeys = Object.keys(enFlat).filter(k => enFlat[k].type === 'string');
logSuccess(`Reference locale en.json has ${enLeafKeys.length} string keys.`);
// 2. Check parity of all other locales against en.json
logInfo('Checking translation parity across all locales...');
for (const [lang, data] of Object.entries(locales)) {
if (lang === 'en') continue;
const langFlat = flattenKeys(data);
for (const key of enLeafKeys) {
if (!langFlat[key]) {
logError(`[${lang}.json] Missing translation key: "${key}"`);
} else if (langFlat[key].type !== 'string') {
logError(`[${lang}.json] Key "${key}" should be string, found ${langFlat[key].type}`);
} else if (typeof langFlat[key].value === 'string' && langFlat[key].value.trim() === '') {
logError(`[${lang}.json] Key "${key}" is empty string`);
}
}
for (const key of Object.keys(langFlat)) {
if (langFlat[key].type === 'string' && !enFlat[key]) {
logError(`[${lang}.json] Extraneous translation key not in en.json: "${key}"`);
}
}
}
// 3. Scan source code for t('...') usages
logInfo('Scanning source code for t(...) translation calls...');
const tRegex = /\bt\(\s*['"`]([^'"`]+)['"`]/g;
function scanDir(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'locales' || entry.name === 'node_modules' || entry.name === 'dist') {
continue;
}
scanDir(fullPath);
} else if (/\.(tsx?|jsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
const content = fs.readFileSync(fullPath, 'utf-8');
const lines = content.split('\n');
const relPath = path.relative(rootDir, fullPath);
lines.forEach((line, lineIndex) => {
// Strip single line comments
const cleanLine = line.replace(/\/\/.*$/, '');
let match;
tRegex.lastIndex = 0;
while ((match = tRegex.exec(cleanLine)) !== null) {
const key = match[1];
const lineNum = lineIndex + 1;
// Check if key exists in en.json
const value = getNestedValue(locales.en, key);
if (value === undefined) {
logError(`${relPath}:${lineNum} - Translation key "${key}" does not exist in en.json`);
} else if (typeof value === 'object' && value !== null) {
logError(
`${relPath}:${lineNum} - Translation key "${key}" returned an object instead of string! Did you mean a nested key like "${key}.${Object.keys(value)[0]}"?`
);
} else if (typeof value !== 'string') {
logError(`${relPath}:${lineNum} - Translation key "${key}" is of type ${typeof value}, expected string`);
}
}
});
}
}
}
scanDir(srcDir);
if (hasErrors) {
console.error('\n\x1b[31m❌ i18n validation failed with errors above.\x1b[0m\n');
process.exit(1);
} else {
console.log('\n\x1b[32m✔ All translation keys and locale files validated successfully!\x1b[0m\n');
process.exit(0);
}