411 lines
12 KiB
JavaScript
411 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* i18n validation script
|
||
* Used by pre-commit hooks to validate i18n translation completeness and consistency.
|
||
*
|
||
* Usage: node scripts/check-i18n.js
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { REQUIRED_MODULES, collectReferenceKeys, getAllKeys } = require('./generate-i18n-types');
|
||
const i18nConfig = require('../packages/desktop/src/common/config/i18n-config.json');
|
||
|
||
const LOCALES_DIR = path.resolve(__dirname, '../packages/desktop/src/renderer/services/i18n/locales');
|
||
const I18N_KEYS_DTS = path.resolve(__dirname, '../packages/desktop/src/renderer/services/i18n/i18n-keys.d.ts');
|
||
const RENDERER_DIR = path.resolve(__dirname, '../packages/desktop/src/renderer');
|
||
const SUPPORTED_LANGUAGES = i18nConfig.supportedLanguages;
|
||
const REFERENCE_LANGUAGE = i18nConfig.referenceLanguage;
|
||
|
||
let hasErrors = false;
|
||
let hasWarnings = false;
|
||
|
||
function logError(message) {
|
||
console.error(`❌ ${message}`);
|
||
hasErrors = true;
|
||
}
|
||
|
||
function logWarning(message) {
|
||
console.warn(`⚠️ ${message}`);
|
||
hasWarnings = true;
|
||
}
|
||
|
||
function logSuccess(message) {
|
||
console.log(`✅ ${message}`);
|
||
}
|
||
|
||
function logInfo(message) {
|
||
console.log(`ℹ️ ${message}`);
|
||
}
|
||
|
||
function extractTypeUnionValues(content, typeName) {
|
||
const match = content.match(new RegExp(`export type ${typeName} =([\\s\\S]*?);`));
|
||
if (!match) {
|
||
return [];
|
||
}
|
||
|
||
const values = [];
|
||
const valueRegex = /'([^']+)'/g;
|
||
for (const item of match[1].matchAll(valueRegex)) {
|
||
values.push(item[1]);
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
function isSameSet(a, b) {
|
||
if (a.size !== b.size) {
|
||
return false;
|
||
}
|
||
|
||
for (const item of a) {
|
||
if (!b.has(item)) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function checkI18nTypeDefinitionInSync() {
|
||
console.log('\n🧩 Checking i18n key type definition sync...\n');
|
||
|
||
if (!fs.existsSync(I18N_KEYS_DTS)) {
|
||
logError(`Missing i18n key type file: ${path.relative(process.cwd(), I18N_KEYS_DTS)}`);
|
||
logError('Run: vx node scripts/generate-i18n-types.js');
|
||
return;
|
||
}
|
||
|
||
const actual = fs.readFileSync(I18N_KEYS_DTS, 'utf-8');
|
||
const actualKeys = new Set(extractTypeUnionValues(actual, 'I18nKey'));
|
||
const expectedKeys = new Set(collectReferenceKeys());
|
||
|
||
if (!isSameSet(actualKeys, expectedKeys)) {
|
||
logError(`Outdated i18n key type file: ${path.relative(process.cwd(), I18N_KEYS_DTS)}`);
|
||
logError('Run: vx node scripts/generate-i18n-types.js');
|
||
return;
|
||
}
|
||
|
||
const actualModules = new Set(extractTypeUnionValues(actual, 'I18nModule'));
|
||
const expectedModules = new Set(REQUIRED_MODULES);
|
||
if (!isSameSet(actualModules, expectedModules)) {
|
||
logError(`Outdated i18n module type file: ${path.relative(process.cwd(), I18N_KEYS_DTS)}`);
|
||
logError('Run: vx node scripts/generate-i18n-types.js');
|
||
return;
|
||
}
|
||
|
||
logSuccess('i18n key type definition is in sync');
|
||
}
|
||
|
||
// Validate directory and file structure
|
||
function checkDirectoryStructure() {
|
||
console.log('\n📁 Checking directory structure...\n');
|
||
|
||
// Validate each locale directory
|
||
for (const lang of SUPPORTED_LANGUAGES) {
|
||
const langDir = path.join(LOCALES_DIR, lang);
|
||
|
||
if (!fs.existsSync(langDir)) {
|
||
logError(`Missing locale directory: ${lang}`);
|
||
continue;
|
||
}
|
||
|
||
logSuccess(`Locale directory exists: ${lang}`);
|
||
|
||
// Validate required module files
|
||
for (const moduleName of REQUIRED_MODULES) {
|
||
const moduleFile = path.join(langDir, `${moduleName}.json`);
|
||
|
||
if (!fs.existsSync(moduleFile)) {
|
||
logError(`Missing module file: ${lang}/${moduleName}.json`);
|
||
continue;
|
||
}
|
||
|
||
// Validate JSON syntax
|
||
try {
|
||
const content = fs.readFileSync(moduleFile, 'utf-8');
|
||
JSON.parse(content);
|
||
} catch (error) {
|
||
logError(`Invalid JSON: ${lang}/${moduleName}.json - ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Validate index.ts
|
||
const indexFile = path.join(langDir, 'index.ts');
|
||
if (!fs.existsSync(indexFile)) {
|
||
logWarning(`Missing index file: ${lang}/index.ts`);
|
||
}
|
||
}
|
||
|
||
// Validate legacy single JSON files are removed
|
||
for (const lang of SUPPORTED_LANGUAGES) {
|
||
const oldFile = path.join(LOCALES_DIR, `${lang}.json`);
|
||
if (fs.existsSync(oldFile)) {
|
||
logError(`Found legacy JSON file, please remove: ${lang}.json`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Validate translation key consistency across locales
|
||
function checkTranslationKeys() {
|
||
console.log('\n🔑 Checking translation key consistency...\n');
|
||
|
||
const referenceLang = REFERENCE_LANGUAGE;
|
||
const referenceKeys = {};
|
||
|
||
// Collect baseline keys from reference locale
|
||
for (const moduleName of REQUIRED_MODULES) {
|
||
const moduleFile = path.join(LOCALES_DIR, referenceLang, `${moduleName}.json`);
|
||
if (fs.existsSync(moduleFile)) {
|
||
try {
|
||
const content = JSON.parse(fs.readFileSync(moduleFile, 'utf-8'));
|
||
referenceKeys[moduleName] = getAllKeys(content);
|
||
} catch {
|
||
logError(`Failed to read reference module: ${referenceLang}/${moduleName}.json`);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Validate other locales against baseline
|
||
for (const lang of SUPPORTED_LANGUAGES) {
|
||
if (lang === referenceLang) continue;
|
||
|
||
logInfo(`Checking ${lang}...`);
|
||
|
||
let missingCount = 0;
|
||
|
||
for (const moduleName of REQUIRED_MODULES) {
|
||
const moduleFile = path.join(LOCALES_DIR, lang, `${moduleName}.json`);
|
||
const expectedKeys = referenceKeys[moduleName] || [];
|
||
|
||
if (fs.existsSync(moduleFile)) {
|
||
try {
|
||
const content = JSON.parse(fs.readFileSync(moduleFile, 'utf-8'));
|
||
const actualKeySet = new Set(getAllKeys(content));
|
||
|
||
const missing = expectedKeys.filter((key) => !actualKeySet.has(key));
|
||
missingCount += missing.length;
|
||
|
||
if (missing.length > 0) {
|
||
logWarning(
|
||
`${lang}/${moduleName}.json is missing ${missing.length} keys: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? '...' : ''}`
|
||
);
|
||
}
|
||
} catch {
|
||
logError(`Failed to read module: ${lang}/${moduleName}.json`);
|
||
}
|
||
}
|
||
}
|
||
|
||
const totalKeys = Object.values(referenceKeys).flat().length;
|
||
const missingPercent = totalKeys > 0 ? ((missingCount / totalKeys) * 100).toFixed(1) : '0.0';
|
||
|
||
if (missingCount > 0) {
|
||
logWarning(`${lang} is missing ${missingCount} keys (${missingPercent}%)`);
|
||
} else {
|
||
logSuccess(`${lang} translations are complete`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function collectEmptyValuePaths(obj, prefix = '') {
|
||
const emptyPaths = [];
|
||
|
||
if (typeof obj !== 'object' || obj === null) {
|
||
return emptyPaths;
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(obj)) {
|
||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||
|
||
if (typeof value === 'object' && value !== null) {
|
||
emptyPaths.push(...collectEmptyValuePaths(value, fullKey));
|
||
continue;
|
||
}
|
||
|
||
if (typeof value === 'string' && value.trim() === '') {
|
||
emptyPaths.push(fullKey);
|
||
}
|
||
}
|
||
|
||
return emptyPaths;
|
||
}
|
||
|
||
// Validate empty translation modules and empty string values
|
||
function checkEmptyTranslations() {
|
||
console.log('\n📭 Checking for empty translations...\n');
|
||
|
||
for (const lang of SUPPORTED_LANGUAGES) {
|
||
for (const moduleName of REQUIRED_MODULES) {
|
||
const moduleFile = path.join(LOCALES_DIR, lang, `${moduleName}.json`);
|
||
|
||
if (fs.existsSync(moduleFile)) {
|
||
try {
|
||
const content = fs.readFileSync(moduleFile, 'utf-8');
|
||
const data = JSON.parse(content);
|
||
|
||
if (Object.keys(data).length === 0) {
|
||
logWarning(`Empty module: ${lang}/${moduleName}.json`);
|
||
continue;
|
||
}
|
||
|
||
const emptyValuePaths = collectEmptyValuePaths(data);
|
||
if (emptyValuePaths.length > 0) {
|
||
logWarning(
|
||
`${lang}/${moduleName}.json has ${emptyValuePaths.length} empty values: ${emptyValuePaths.slice(0, 3).join(', ')}${emptyValuePaths.length > 3 ? '...' : ''}`
|
||
);
|
||
}
|
||
} catch {
|
||
// Already reported by other checks
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function collectAllCodeFiles(dir) {
|
||
const files = [];
|
||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
|
||
for (const entry of entries) {
|
||
if (entry.name === 'i18n-keys.d.ts') {
|
||
continue;
|
||
}
|
||
|
||
const fullPath = path.join(dir, entry.name);
|
||
|
||
if (entry.isDirectory()) {
|
||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'out') {
|
||
continue;
|
||
}
|
||
files.push(...collectAllCodeFiles(fullPath));
|
||
continue;
|
||
}
|
||
|
||
if (entry.name.endsWith('.ts') || entry.name.endsWith('.tsx')) {
|
||
files.push(fullPath);
|
||
}
|
||
}
|
||
|
||
return files;
|
||
}
|
||
|
||
function stripComments(code) {
|
||
return code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||
}
|
||
|
||
function buildReferenceKeySet() {
|
||
const keySet = new Set();
|
||
|
||
for (const moduleName of REQUIRED_MODULES) {
|
||
const moduleFile = path.join(LOCALES_DIR, REFERENCE_LANGUAGE, `${moduleName}.json`);
|
||
if (!fs.existsSync(moduleFile)) {
|
||
continue;
|
||
}
|
||
|
||
const content = JSON.parse(fs.readFileSync(moduleFile, 'utf-8'));
|
||
const keys = getAllKeys(content);
|
||
for (const key of keys) {
|
||
keySet.add(`${moduleName}.${key}`);
|
||
}
|
||
}
|
||
|
||
return keySet;
|
||
}
|
||
|
||
function checkLiteralKeyUsages() {
|
||
console.log('\n🧪 Checking literal t() key usages...\n');
|
||
|
||
const referenceKeySet = buildReferenceKeySet();
|
||
const files = collectAllCodeFiles(RENDERER_DIR);
|
||
const keyRegex = /\b(?:i18n\.)?t\(\s*(['"`])([^'"`]+)\1/g;
|
||
|
||
let invalidCount = 0;
|
||
|
||
for (const file of files) {
|
||
const content = fs.readFileSync(file, 'utf-8');
|
||
const code = stripComments(content);
|
||
|
||
for (const match of code.matchAll(keyRegex)) {
|
||
const key = match[2].trim();
|
||
|
||
if (!key || key.includes('${') || key.startsWith('http://') || key.startsWith('https://')) {
|
||
continue;
|
||
}
|
||
|
||
if (!key.includes('.')) {
|
||
continue;
|
||
}
|
||
|
||
if (!referenceKeySet.has(key)) {
|
||
invalidCount += 1;
|
||
logWarning(`Unknown i18n key: ${key} (${path.relative(process.cwd(), file)})`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (invalidCount === 0) {
|
||
logSuccess('No invalid literal i18n keys found in renderer code');
|
||
} else {
|
||
logInfo(`Found ${invalidCount} unknown literal i18n keys (warning only)`);
|
||
}
|
||
}
|
||
|
||
// Validate i18n runtime config
|
||
function checkIndexConfig() {
|
||
console.log('\n⚙️ Checking i18n configuration...\n');
|
||
|
||
const indexFile = path.join(__dirname, '../packages/desktop/src/renderer/services/i18n/index.ts');
|
||
|
||
if (!fs.existsSync(indexFile)) {
|
||
logError('Missing i18n config file: packages/desktop/src/renderer/services/i18n/index.ts');
|
||
return;
|
||
}
|
||
|
||
const content = fs.readFileSync(indexFile, 'utf-8');
|
||
|
||
if (!content.includes('i18n-config.json')) {
|
||
logError('i18n config should load shared constants from src/common/config/i18n-config.json');
|
||
}
|
||
|
||
if (!content.includes('export const supportedLanguages')) {
|
||
logError('i18n config should export supportedLanguages');
|
||
}
|
||
|
||
// Ensure lazy loading support exists
|
||
if (!content.includes('loadLocaleModules') && !content.includes('import(')) {
|
||
logWarning('i18n config may not be using lazy loading');
|
||
}
|
||
|
||
logSuccess('i18n configuration check passed');
|
||
}
|
||
|
||
function main() {
|
||
console.log('\n🔍 i18n validation started\n');
|
||
console.log('========================================');
|
||
|
||
checkDirectoryStructure();
|
||
checkTranslationKeys();
|
||
checkEmptyTranslations();
|
||
checkLiteralKeyUsages();
|
||
checkI18nTypeDefinitionInSync();
|
||
checkIndexConfig();
|
||
|
||
console.log('\n========================================');
|
||
console.log('\n📊 Validation summary:\n');
|
||
|
||
if (hasErrors) {
|
||
console.log('❌ Validation failed. Please fix the issues before committing.');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (hasWarnings) {
|
||
console.log('⚠️ Warnings found.');
|
||
}
|
||
|
||
console.log('✅ i18n validation passed\n');
|
||
process.exit(0);
|
||
}
|
||
|
||
main();
|