Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
<title>Codex Switcher</title>
</head>

<body>
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "npm run check:i18n",
"build": "tsc && vite build",
"check:i18n": "node scripts/check-i18n.mjs && node --test scripts/i18n-runtime.test.mjs",
"preview": "vite preview",
"tauri": "tauri"
},
Expand Down
114 changes: 114 additions & 0 deletions scripts/check-i18n.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';

const sourceRoot = path.resolve('src');
const localePath = path.join(sourceRoot, 'i18n', 'ru.ts');
const han = /\p{Script=Han}/u;
const normalize = value => value.replace(/\s+/g, ' ').trim();

function sourceFile(file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
return ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true, kind);
}

function visit(node, callback) {
callback(node);
ts.forEachChild(node, child => visit(child, callback));
}

function sourceFiles(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(file);
return [file];
});
}

const localeSource = sourceFile(localePath);
const translations = new Map();
const errors = [];

visit(localeSource, node => {
if (!ts.isVariableDeclaration(node) || node.name.getText(localeSource) !== 'replacements') return;
if (!node.initializer || !ts.isArrayLiteralExpression(node.initializer)) return;
for (const entry of node.initializer.elements) {
if (!ts.isArrayLiteralExpression(entry) || entry.elements.length !== 2) {
errors.push('ru.ts: каталог должен состоять из пар [исходный текст, перевод]');
continue;
}
const [source, target] = entry.elements;
if (!ts.isStringLiteralLike(source) || !ts.isStringLiteralLike(target)) {
errors.push('ru.ts: исходный текст и перевод должны быть строковыми литералами');
continue;
}
const key = normalize(source.text);
if (translations.has(key)) {
errors.push(`ru.ts: повтор исходной строки ${JSON.stringify(key)}`);
} else {
translations.set(key, target.text);
}
if (han.test(target.text)) {
errors.push(`ru.ts: в переводе остались иероглифы: ${JSON.stringify(target.text)}`);
}
}
});

if (translations.size === 0) errors.push('ru.ts: каталог переводов не найден или пуст');

for (const [source, target] of translations) {
const sourcePlaceholders = source.match(/\{[^{}]*\}/g)?.length ?? 0;
const targetPlaceholders = target.match(/\{[^{}]*\}/g)?.length ?? 0;
if (sourcePlaceholders !== targetPlaceholders) {
errors.push(`ru.ts: число подстановок не совпадает: ${JSON.stringify(source)} -> ${JSON.stringify(target)}`);
}
}

function checkLiteral(file, ast, node, value, kind) {
const text = normalize(value);
if (!text || !han.test(text)) return;
if (translations.has(text)) return;
const { line } = ast.getLineAndCharacterOfPosition(node.getStart(ast));
errors.push(`${path.relative(process.cwd(), file)}:${line + 1}: нет точного перевода ${kind} ${JSON.stringify(text)}`);
}

for (const file of sourceFiles(sourceRoot).filter(file => /\.tsx?$/.test(file))) {
if (file.startsWith(path.join(sourceRoot, 'i18n') + path.sep)) continue;
const ast = sourceFile(file);
visit(ast, node => {
if (ts.isJsxText(node)) {
checkLiteral(file, ast, node, node.getText(ast), 'JSX-текста');
} else if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
checkLiteral(file, ast, node, node.text, 'строки');
} else if (ts.isTemplateHead(node) || ts.isTemplateMiddle(node) || ts.isTemplateTail(node)) {
checkLiteral(file, ast, node, node.text, 'части шаблона');
}
});
}

const rustRoot = path.resolve('src-tauri', 'src');
const rustCandidate = /(Err\b|map_err|ok_or|unwrap_or|return\s+"|=>\s*"|Ok\(|format!|\.into\(\)|to_string\(\))/;
const rustLog = /(println!|eprintln!|tracing::|debug!|info!|warn!|trace!)/;

for (const file of sourceFiles(rustRoot).filter(file => file.endsWith('.rs'))) {
if (file.includes(`${path.sep}i18n${path.sep}`)) continue;
let lines = fs.readFileSync(file, 'utf8').split('\n');
const tests = lines.findIndex(line => /#\[cfg\(test\)\]/.test(line));
if (tests >= 0) lines = lines.slice(0, tests);
lines.forEach((line, index) => {
if (/^\s*\/\//.test(line) || rustLog.test(line) || !rustCandidate.test(line)) return;
for (const match of line.matchAll(/"((?:\\.|[^"\\])*)"/g)) {
const text = normalize(match[1].replace(/\\n/g, ' ').replace(/\\"/g, '"'));
if (!han.test(text) || translations.has(text)) continue;
errors.push(`${path.relative(process.cwd(), file)}:${index + 1}: нет перевода возможного сообщения backend ${JSON.stringify(text)}`);
}
});
}

if (errors.length > 0) {
console.error(`Проверка локализации не пройдена (${errors.length}):`);
for (const error of errors) console.error(`- ${error}`);
process.exitCode = 1;
} else {
console.log(`Проверка локализации пройдена: ${translations.size} уникальных русских строк.`);
}
1 change: 1 addition & 0 deletions scripts/fixtures/i18n-data.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><html><head><meta charset="UTF-8"><title>i18n data regression</title></head><body><div id="root"></div><script type="module" src="./i18n-data.tsx"></script></body></html>
19 changes: 19 additions & 0 deletions scripts/fixtures/i18n-data.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { installAppLocale } from '../../src/i18n';
import '../../src/App.css';

installAppLocale('ru-RU');

function Fixture() {
const [name, setName] = useState('生产账号A');
return <main style={{ padding: 24 }}>
<h2>账号</h2>
<p>复制 <span translate="no" title={name} data-testid="account-name">{name}</span></p>
<p translate="no" data-testid="user-notes">客户备注:测试账号。保持原文。</p>
<p data-i18n-ignore data-testid="ignored-data">自定义账号,测试。</p>
<button onClick={() => setName('客户测试账号B')}>刷新</button>
</main>;
}

createRoot(document.getElementById('root')!).render(<Fixture />);
86 changes: 86 additions & 0 deletions scripts/i18n-runtime.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import ts from 'typescript';

const source = fs.readFileSync('src/i18n/ru.ts', 'utf8');
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const module = { exports: {} };
vm.runInNewContext(code, { module, exports: module.exports });
const { translateRu } = module.exports;

function transpileModule(file, context = {}) {
const source = fs.readFileSync(file, 'utf8');
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText;
const loaded = { exports: {} };
vm.runInNewContext(code, {
module: loaded,
exports: loaded.exports,
require: name => context[name] ?? {},
});
return loaded.exports;
}

const { resolveAppLocale } = transpileModule('src/i18n/index.ts', {
'./ru': { russianLocale: {} },
'./runtime': { installUiLocale() {} },
});

test('system locale selects Russian and unsupported locales fall back to Chinese', () => {
assert.equal(resolveAppLocale('ru'), 'ru');
assert.equal(resolveAppLocale('ru-RU'), 'ru');
assert.equal(resolveAppLocale('zh-CN'), 'zh-CN');
assert.equal(resolveAppLocale('en-US'), 'zh-CN');
assert.equal(resolveAppLocale(''), 'zh-CN');
});

test('dynamic backend messages retain values and prefer the most specific template', () => {
const cases = [
['模型列表请求失败: timeout', 'Не удалось запросить список моделей: timeout'],
[
'Server 不可达(primary=http://a, fallback=http://b)',
'Server недоступен (основной адрес: http://a, резервный: http://b)',
],
['已重置 2 个限额窗口', 'Сброшено окон лимита: 2'],
];
for (const [input, expected] of cases) assert.equal(translateRu(input), expected);
});

test('complete messages win over fragment replacements', () => {
assert.equal(
translateRu('Fast 模式已开启(2x 额度消耗,更快推理)。重启 Codex 生效。'),
'Режим Fast включён: ответы быстрее, расход квоты удвоен. Перезапустите Codex для применения',
);
});

test('runtime leaves opted-out user text and attributes unchanged', () => {
const source = fs.readFileSync('src/i18n/runtime.ts', 'utf8')
+ '\nexport { translateTextNode, translateElementAttributes };';
const loaded = { exports: {} };
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
}).outputText, { module: loaded, exports: loaded.exports });
const { translateTextNode, translateElementAttributes } = loaded.exports;
const locale = { translate: translateRu };
for (const marker of ['[translate="no"]', '[data-i18n-ignore]']) {
const attributes = new Map([['title', '生产账号A']]);
const parent = {
closest: selector => selector.includes(marker) ? parent : null,
getAttribute: key => attributes.get(key),
setAttribute: (key, value) => attributes.set(key, value),
};
const text = { data: '生产账号A', parentElement: parent };
translateTextNode(text, locale);
translateElementAttributes(parent, locale);
assert.equal(text.data, '生产账号A');
assert.equal(attributes.get('title'), '生产账号A');
}
const text = { data: '账号', parentElement: { closest: () => null } };
translateTextNode(text, locale);
assert.equal(text.data, 'Аккаунт');
});
Loading