chore: 增加 i18n 检查脚本与提交钩子

This commit is contained in:
alger
2026-02-04 20:09:11 +08:00
parent cd1c09889f
commit 14e35c7667
5 changed files with 244 additions and 3 deletions

5
.gitignore vendored
View File

@@ -32,6 +32,11 @@ android/app/release
.cursor .cursor
.windsurf .windsurf
.agent .agent
.claude
CLAUDE.md
AGENTS.md
.sisyphus
.auto-imports.d.ts .auto-imports.d.ts

View File

@@ -4,4 +4,7 @@ npx lint-staged
echo "运行类型检查..." echo "运行类型检查..."
npm run typecheck npm run typecheck
echo "运行国际化检查..."
npm run lint:i18n
echo "所有检查通过,准备提交..." echo "所有检查通过,准备提交..."

View File

@@ -8,7 +8,8 @@
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
"format": "prettier --write ./src", "format": "prettier --write ./src",
"lint": "eslint ./src --fix", "lint": "eslint ./src --fix && npm run lint:i18n",
"lint:i18n": "bun scripts/check_i18n.ts",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "npm run typecheck:node && npm run typecheck:web", "typecheck": "npm run typecheck:node && npm run typecheck:web",
@@ -52,7 +53,6 @@
"node-id3": "^0.2.9", "node-id3": "^0.2.9",
"node-machine-id": "^1.1.12", "node-machine-id": "^1.1.12",
"pinia-plugin-persistedstate": "^4.7.1", "pinia-plugin-persistedstate": "^4.7.1",
"sharp": "^0.34.5",
"vue-i18n": "^11.2.2" "vue-i18n": "^11.2.2"
}, },
"devDependencies": { "devDependencies": {
@@ -78,7 +78,7 @@
"autoprefixer": "^10.4.22", "autoprefixer": "^10.4.22",
"axios": "^1.13.2", "axios": "^1.13.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"electron": "^39.2.7", "electron": "^40.1.0",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-vite": "^5.0.0", "electron-vite": "^5.0.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",

103
scripts/check_i18n.ts Normal file
View File

@@ -0,0 +1,103 @@
import fs from 'fs';
import path from 'path';
async function main() {
const langDir = path.join(process.cwd(), 'src/i18n/lang');
const sourceLang = 'zh-CN';
const targetLangs = ['en-US', 'ja-JP', 'ko-KR', 'zh-Hant'];
const sourcePath = path.join(langDir, sourceLang);
const files = fs.readdirSync(sourcePath).filter((f) => f.endsWith('.ts'));
function getFlatObject(obj: any, prefix = ''): Record<string, string> {
const result: Record<string, string> = {};
for (const key in obj) {
const val = obj[key];
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
Object.assign(result, getFlatObject(val, fullKey));
} else {
result[fullKey] = String(val);
}
}
return result;
}
const report: any = {};
let hasMissing = false;
for (const fileName of files) {
// Dynamic import might be tricky with ESM/TS in Bun without proper setup
// We'll read the file and extract the export default object using a simple regex/eval approach
// or just use Bun.file and eval if it's safe enough for this tool.
const getContent = async (filePath: string) => {
if (!fs.existsSync(filePath)) return null;
const content = fs.readFileSync(filePath, 'utf-8');
// Simple extraction of the default export object
const match = content.match(/export\s+default\s+([\s\S]+);/);
if (!match) return null;
try {
// This is a bit hacky but works for simple object literals in i18n files
// We replace potential comments and import statements if they existed,
// but here it's mostly just a JS object.
const objStr = match[1].trim();
// Remove trailing comma if it exists before closing brace
// objStr = objStr.replace(/,\s*}/g, '}');
// Use a more robust way: wrap in () and eval
// Note: this assumes the i18n files don't have complex logic or external imports
return eval(`(${objStr})`);
} catch (e) {
console.error(`Error parsing ${filePath}:`, e);
return null;
}
};
const sourceObj = await getContent(path.join(sourcePath, fileName));
if (!sourceObj) continue;
const sourceKeysMap = getFlatObject(sourceObj);
const sourceKeys = Object.keys(sourceKeysMap);
for (const lang of targetLangs) {
if (!report[lang]) report[lang] = {};
const targetFilePath = path.join(langDir, lang, fileName);
const targetObj = await getContent(targetFilePath);
const targetKeysMap = targetObj ? getFlatObject(targetObj) : {};
const targetKeys = Object.keys(targetKeysMap);
const missing = sourceKeys.filter((k) => !targetKeys.includes(k));
if (missing.length > 0) {
hasMissing = true;
report[lang][fileName] = missing.map((k) => ({
key: k,
zh: sourceKeysMap[k]
}));
}
}
}
if (hasMissing) {
console.error('发现国际化键值缺失:');
for (const lang in report) {
const files = report[lang];
if (Object.keys(files).length === 0) continue;
console.error(`\n语言: ${lang}`);
for (const fileName in files) {
console.error(` 文件: ${fileName}`);
files[fileName].forEach((item: any) => {
console.error(` - [${item.key}]: ${item.zh}`);
});
}
}
process.exit(1);
} else {
console.log('所有国际化键值检查通过!');
process.exit(0);
}
}
main();

View File

@@ -0,0 +1,130 @@
import fs from 'fs';
import path from 'path';
async function main() {
const rootDir = process.cwd();
const langDir = path.join(rootDir, 'src/i18n/lang/zh-CN');
const definedKeys = new Set<string>();
const langFiles = fs.readdirSync(langDir).filter((f) => f.endsWith('.ts'));
function getKeys(obj: any, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && obj[key] !== null && !Array.isArray(obj[key])) {
getKeys(obj[key], fullKey);
} else {
definedKeys.add(fullKey);
}
}
}
for (const file of langFiles) {
const content = fs.readFileSync(path.join(langDir, file), 'utf-8');
const match = content.match(/export\s+default\s+([\s\S]+);/);
if (match) {
try {
const obj = eval(`(${match[1]})`);
getKeys(obj, file.replace('.ts', ''));
} catch (error) {
console.warn('Failed to parse i18n file:', file, error);
}
}
}
// @ts-ignore
const glob = new Bun.Glob('src/renderer/**/*.{vue,ts,js}');
// @ts-ignore
const files = Array.from(
glob.scanSync({
cwd: rootDir,
onlyFiles: true
})
);
const report = {
hardcodedChinese: [] as any[],
missingKeys: [] as any[]
};
const chineseMatchRegex = /[\u4e00-\u9fa5]+/g;
const i18nRegex = /\bt\(['"]([^'"]+)['"]\)/g;
for (const relativeFile of files) {
const rel = relativeFile as string;
if (
rel.includes('node_modules') ||
rel.includes('android/') ||
rel.includes('resources/') ||
rel.includes('scripts/') ||
rel.endsWith('.d.ts')
)
continue;
const file = path.join(rootDir, rel);
let content = fs.readFileSync(file, 'utf-8');
content = content.replace(/\/\*[\s\S]*?\*\//g, (match) => {
const lines = match.split('\n').length - 1;
return '\n'.repeat(lines);
});
content = content.replace(/<!--[\s\S]*?-->/g, (match) => {
const lines = match.split('\n').length - 1;
return '\n'.repeat(lines);
});
const lines = content.split('\n');
let isInConsole = false;
lines.forEach((line, index) => {
const lineNumber = index + 1;
const cleanLine = line.split('//')[0];
if (cleanLine.includes('console.')) {
isInConsole = true;
}
if (!isInConsole && !cleanLine.includes('import')) {
const chineseMatches = cleanLine.match(chineseMatchRegex);
if (chineseMatches) {
chineseMatches.forEach((text) => {
report.hardcodedChinese.push({
file: rel,
line: lineNumber,
text: text.trim(),
context: line.trim()
});
});
}
}
if (isInConsole && cleanLine.includes(');')) {
isInConsole = false;
}
let i18nMatch;
while ((i18nMatch = i18nRegex.exec(cleanLine)) !== null) {
const key = i18nMatch[1];
if (!definedKeys.has(key)) {
report.missingKeys.push({
file: rel,
line: lineNumber,
key: key,
context: line.trim()
});
}
}
});
}
const outputPath = path.join(rootDir, 'i18n_report.json');
fs.writeFileSync(outputPath, JSON.stringify(report, null, 2));
console.log(`\n报告生成成功`);
console.log(`- 硬编码中文: ${report.hardcodedChinese.length}`);
console.log(`- 缺失的 Key: ${report.missingKeys.length}`);
console.log(`- 报告路径: ${outputPath}\n`);
}
main();