Files
2026-07-13 13:20:22 +08:00

411 lines
12 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.
#!/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();