项目文件夹

文件
2026-07-13 13:20:22 +08:00

411 行
12 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
#!/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();