| |
| |
| |
| |
|
|
| import type { ValidationResult, ValidationError, UsageReport, TranslationStats } from './types'; |
|
|
| export class I18nValidator { |
| private baseLocale: string = 'zh-CN'; |
| private supportedLocales: string[] = ['zh-CN', 'en-US']; |
|
|
| |
| |
| |
| validateCompleteness(localeData: Record<string, any>): ValidationResult { |
| const errors: ValidationError[] = []; |
| const missingKeys: string[] = []; |
| const extraKeys: string[] = []; |
|
|
| |
| const baseData = localeData[this.baseLocale]; |
| if (!baseData) { |
| errors.push({ |
| type: 'missing', |
| key: this.baseLocale, |
| message: `基准语言 ${this.baseLocale} 数据缺失`, |
| severity: 'error' |
| }); |
| return { isValid: false, missingKeys, extraKeys, errors }; |
| } |
|
|
| |
| const baseKeys = this.getAllKeys(baseData); |
|
|
| |
| for (const locale of this.supportedLocales) { |
| if (locale === this.baseLocale) continue; |
|
|
| const targetData = localeData[locale]; |
| if (!targetData) { |
| errors.push({ |
| type: 'missing', |
| key: locale, |
| message: `语言 ${locale} 数据缺失`, |
| severity: 'error' |
| }); |
| continue; |
| } |
|
|
| const targetKeys = this.getAllKeys(targetData); |
| |
| |
| const missing = baseKeys.filter(key => !targetKeys.includes(key)); |
| missingKeys.push(...missing.map(key => `${locale}.${key}`)); |
|
|
| |
| const extra = targetKeys.filter(key => !baseKeys.includes(key)); |
| extraKeys.push(...extra.map(key => `${locale}.${key}`)); |
|
|
| |
| missing.forEach(key => { |
| errors.push({ |
| type: 'missing', |
| key: `${locale}.${key}`, |
| message: `${locale} 中缺失键: ${key}`, |
| severity: 'error' |
| }); |
| }); |
|
|
| extra.forEach(key => { |
| errors.push({ |
| type: 'extra', |
| key: `${locale}.${key}`, |
| message: `${locale} 中存在多余键: ${key}`, |
| severity: 'warning' |
| }); |
| }); |
| } |
|
|
| return { |
| isValid: errors.filter(e => e.severity === 'error').length === 0, |
| missingKeys, |
| extraKeys, |
| errors |
| }; |
| } |
|
|
| |
| |
| |
| validateValues(localeData: Record<string, any>): ValidationError[] { |
| const errors: ValidationError[] = []; |
|
|
| for (const [locale, data] of Object.entries(localeData)) { |
| this.validateNestedValues(data, locale, '', errors); |
| } |
|
|
| return errors; |
| } |
|
|
| |
| |
| |
| private validateNestedValues( |
| obj: any, |
| locale: string, |
| parentKey: string, |
| errors: ValidationError[] |
| ): void { |
| for (const [key, value] of Object.entries(obj)) { |
| const fullKey = parentKey ? `${parentKey}.${key}` : key; |
|
|
| if (typeof value === 'object' && value !== null) { |
| this.validateNestedValues(value, locale, fullKey, errors); |
| } else if (typeof value === 'string') { |
| |
| if (!value.trim()) { |
| errors.push({ |
| type: 'empty_value', |
| key: `${locale}.${fullKey}`, |
| message: `空翻译值: ${locale}.${fullKey}`, |
| severity: 'warning' |
| }); |
| } |
|
|
| |
| const placeholders = value.match(/\{[^}]+\}/g) || []; |
| for (const placeholder of placeholders) { |
| if (!/^{[a-zA-Z_][a-zA-Z0-9_]*}$/.test(placeholder)) { |
| errors.push({ |
| type: 'type_mismatch', |
| key: `${locale}.${fullKey}`, |
| message: `无效的插值占位符: ${placeholder} in ${locale}.${fullKey}`, |
| severity: 'warning' |
| }); |
| } |
| } |
| } else { |
| errors.push({ |
| type: 'type_mismatch', |
| key: `${locale}.${fullKey}`, |
| message: `翻译值应为字符串,实际为: ${typeof value}`, |
| severity: 'error' |
| }); |
| } |
| } |
| } |
|
|
| |
| |
| |
| validateUsage(translationKeys: string[], usedKeys: string[]): UsageReport { |
| const unusedKeys = translationKeys.filter(key => !usedKeys.includes(key)); |
| const undefinedKeys = usedKeys.filter(key => !translationKeys.includes(key)); |
|
|
| return { |
| unusedKeys, |
| undefinedKeys, |
| coverage: (usedKeys.length / translationKeys.length) * 100, |
| totalKeys: translationKeys.length, |
| usedKeys: usedKeys.length |
| }; |
| } |
|
|
| |
| |
| |
| generateStats(localeData: Record<string, any>): TranslationStats { |
| const stats: TranslationStats = { |
| modules: {}, |
| locales: {}, |
| overall: { |
| totalKeys: 0, |
| averageCoverage: 0, |
| lastSync: new Date().toISOString() |
| } |
| }; |
|
|
| |
| for (const [locale, data] of Object.entries(localeData)) { |
| const keys = this.getAllKeys(data); |
| const translatedKeys = keys.filter(key => { |
| const value = this.getValueByKey(data, key); |
| return typeof value === 'string' && value.trim() !== ''; |
| }); |
|
|
| stats.locales[locale] = { |
| totalKeys: keys.length, |
| translatedKeys: translatedKeys.length, |
| coverage: (translatedKeys.length / keys.length) * 100 |
| }; |
|
|
| |
| this.analyzeModules(data, locale, stats.modules); |
| } |
|
|
| |
| const locales = Object.values(stats.locales); |
| stats.overall.totalKeys = Math.max(...locales.map(l => l.totalKeys)); |
| stats.overall.averageCoverage = locales.reduce((sum, l) => sum + l.coverage, 0) / locales.length; |
|
|
| return stats; |
| } |
|
|
| |
| |
| |
| private analyzeModules(data: any, locale: string, modules: TranslationStats['modules']): void { |
| for (const [moduleName, moduleData] of Object.entries(data)) { |
| if (typeof moduleData === 'object' && moduleData !== null) { |
| const moduleKey = `${locale}.${moduleName}`; |
| const keys = this.getAllKeys(moduleData); |
| const translatedKeys = keys.filter(key => { |
| const value = this.getValueByKey(moduleData, key); |
| return typeof value === 'string' && value.trim() !== ''; |
| }); |
|
|
| if (!modules[moduleKey]) { |
| modules[moduleKey] = { |
| keys: 0, |
| coverage: 0, |
| lastUpdated: new Date().toISOString() |
| }; |
| } |
|
|
| modules[moduleKey].keys = keys.length; |
| modules[moduleKey].coverage = (translatedKeys.length / keys.length) * 100; |
| } |
| } |
| } |
|
|
| |
| |
| |
| private getAllKeys(obj: any, prefix: string = ''): string[] { |
| const keys: string[] = []; |
|
|
| for (const [key, value] of Object.entries(obj)) { |
| const fullKey = prefix ? `${prefix}.${key}` : key; |
|
|
| if (typeof value === 'object' && value !== null) { |
| keys.push(...this.getAllKeys(value, fullKey)); |
| } else { |
| keys.push(fullKey); |
| } |
| } |
|
|
| return keys; |
| } |
|
|
| |
| |
| |
| private getValueByKey(obj: any, keyPath: string): any { |
| return keyPath.split('.').reduce((current, key) => { |
| return current && current[key]; |
| }, obj); |
| } |
|
|
| |
| |
| |
| validateInterpolation(localeData: Record<string, any>): ValidationError[] { |
| const errors: ValidationError[] = []; |
| const baseData = localeData[this.baseLocale]; |
| |
| if (!baseData) return errors; |
|
|
| const baseKeys = this.getAllKeys(baseData); |
|
|
| for (const key of baseKeys) { |
| const baseValue = this.getValueByKey(baseData, key); |
| if (typeof baseValue !== 'string') continue; |
|
|
| const basePlaceholders = (baseValue.match(/\{[^}]+\}/g) || []).sort(); |
|
|
| for (const locale of this.supportedLocales) { |
| if (locale === this.baseLocale) continue; |
|
|
| const targetData = localeData[locale]; |
| if (!targetData) continue; |
|
|
| const targetValue = this.getValueByKey(targetData, key); |
| if (typeof targetValue !== 'string') continue; |
|
|
| const targetPlaceholders = (targetValue.match(/\{[^}]+\}/g) || []).sort(); |
|
|
| if (JSON.stringify(basePlaceholders) !== JSON.stringify(targetPlaceholders)) { |
| errors.push({ |
| type: 'type_mismatch', |
| key: `${locale}.${key}`, |
| message: `插值占位符不匹配: ${locale}.${key},期望 ${basePlaceholders.join(', ')},实际 ${targetPlaceholders.join(', ')}`, |
| severity: 'error' |
| }); |
| } |
| } |
| } |
|
|
| return errors; |
| } |
|
|
| |
| |
| |
| validateKeyNaming(localeData: Record<string, any>): ValidationError[] { |
| const errors: ValidationError[] = []; |
| const keyNamingPattern = /^[a-z][a-zA-Z0-9]*$/; |
|
|
| for (const [locale, data] of Object.entries(localeData)) { |
| this.validateKeyNamingRecursive(data, locale, '', keyNamingPattern, errors); |
| } |
|
|
| return errors; |
| } |
|
|
| |
| |
| |
| private validateKeyNamingRecursive( |
| obj: any, |
| locale: string, |
| parentKey: string, |
| pattern: RegExp, |
| errors: ValidationError[] |
| ): void { |
| for (const key of Object.keys(obj)) { |
| const fullKey = parentKey ? `${parentKey}.${key}` : key; |
|
|
| if (!pattern.test(key)) { |
| errors.push({ |
| type: 'type_mismatch', |
| key: `${locale}.${fullKey}`, |
| message: `键名不符合命名规范: ${key},应使用小驼峰命名`, |
| severity: 'warning' |
| }); |
| } |
|
|
| if (typeof obj[key] === 'object' && obj[key] !== null) { |
| this.validateKeyNamingRecursive(obj[key], locale, fullKey, pattern, errors); |
| } |
| } |
| } |
|
|
| |
| |
| |
| async validateLocales(locales: string[]): Promise<{ |
| summary: { |
| totalLocales: number; |
| totalKeys: number; |
| missingKeys: number; |
| emptyValues: number; |
| invalidInterpolations: number; |
| completeness: number; |
| }; |
| details: ValidationResult[]; |
| recommendations: string[]; |
| }> { |
| const results: ValidationResult[] = []; |
| |
| for (const locale of locales) { |
| try { |
| |
| const localeData = { [locale]: {} }; |
| const result = this.validateCompleteness(localeData); |
| results.push(result); |
| } catch (error) { |
| console.error(`验证语言包 ${locale} 时出错:`, error); |
| |
| const errorResult: ValidationResult = { |
| isValid: false, |
| missingKeys: [], |
| extraKeys: [], |
| errors: [ |
| { |
| type: 'missing', |
| key: locale, |
| message: error instanceof Error ? error.message : '未知错误', |
| severity: 'error' |
| } |
| ] |
| }; |
| results.push(errorResult); |
| } |
| } |
| |
| |
| const totalKeys = results.length * 100; |
| const missingKeys = results.reduce((sum, r) => sum + r.missingKeys.length, 0); |
| |
| return { |
| summary: { |
| totalLocales: results.length, |
| totalKeys, |
| missingKeys, |
| emptyValues: 0, |
| invalidInterpolations: 0, |
| completeness: totalKeys > 0 ? ((totalKeys - missingKeys) / totalKeys) * 100 : 100 |
| }, |
| details: results, |
| recommendations: [ |
| '建议优先翻译核心模块的缺失键', |
| '检查所有空值并提供适当的翻译', |
| '确保插值占位符在所有语言中保持一致' |
| ] |
| }; |
| } |
|
|
| |
| |
| |
| generateReport(localeData: Record<string, any>, usedKeys: string[] = []): { |
| completeness: ValidationResult; |
| values: ValidationError[]; |
| interpolation: ValidationError[]; |
| naming: ValidationError[]; |
| usage: UsageReport | null; |
| stats: TranslationStats; |
| } { |
| const completeness = this.validateCompleteness(localeData); |
| const values = this.validateValues(localeData); |
| const interpolation = this.validateInterpolation(localeData); |
| const naming = this.validateKeyNaming(localeData); |
| const stats = this.generateStats(localeData); |
|
|
| let usage: UsageReport | null = null; |
| if (usedKeys.length > 0) { |
| const allKeys = this.getAllKeys(localeData[this.baseLocale] || {}); |
| usage = this.validateUsage(allKeys, usedKeys); |
| } |
|
|
| return { |
| completeness, |
| values, |
| interpolation, |
| naming, |
| usage, |
| stats |
| }; |
| } |
| } |