159 lines
5.1 KiB
JavaScript
159 lines
5.1 KiB
JavaScript
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);
|
||
}
|