Привет, Хабр! Меня зовут Нияз, frontend тимлид из Казахстана. Это мой первый пост — делюсь скриптами, которые сэкономили неделю работы.
Проблема
HR-платформа, 8000+ TypeScript файлов, весь текст захардкожен на русском. Бизнес хочет английский и казахский.
<Button>Сохранить</Button>
<span>Привет, {userName}!</span>
const error = "Произошла ошибка";
Руками — это неделя копипасты и сотни пропущенных строк. Решил написать скрипты.
Результат
Метрика |
Значение |
Ключей перевода |
9,823 |
Вызовов t() в коде |
39,086 |
Файлов обработано |
8,198 |
Время работы скриптов |
~5 минут |
Пайплайн
EXTRACT → SYNC → TRANSLATE → CONVERT
4 скрипта, каждый делает одну задачу.
1. extract-russian.mjs
Что делает: Находит все русские строки, генерирует ключи, заменяет на t().
const RUSSIAN_REGEX = /[а-яёА-ЯЁ]/;
traverse(ast, {
StringLiteral(path) {
if (!RUSSIAN_REGEX.test(path.node.value)) return;
if (isInsideTCall(path)) return; // уже обёрнуто
if (isInsideConsoleLog(path)) return; // не переводим логи
const key = generateKey(value); // транслитерация
path.replaceWith(t.callExpression(t.identifier('t'), [t.stringLiteral(key)]));
}
});
node scripts/extract-russian.mjs --mode=report # посмотреть
node scripts/extract-russian.mjs --mode=extract # заменить
node scripts/extract-russian.mjs --mode=validate # проверить ключи
2. sync-locales.mjs
Что делает: Синхронизирует структуру JSON-файлов. После извлечения в ru.json есть ключи, которых нет в en.json и kk.json — скрипт добавляет их с пустыми значениями.
node scripts/sync-locales.mjs
# ru: 9819/9819 (100%)
# en: 1096/9819 (11%)
# kk: 1096/9819 (11%)
3. translate-locales.mjs
Что делает: Переводит пустые строки через API. DeepL для английского, Google Translate для казахского (DeepL его не поддерживает).
async function translateWithDeepL(texts, targetLang) {
const response = await fetch(DEEPL_API_URL, {
method: 'POST',
headers: { 'Authorization': `DeepL-Auth-Key ${API_KEY}` },
body: JSON.stringify({ text: texts, source_lang: 'RU', target_lang: targetLang })
});
return (await response.json()).translations.map(t => t.text);
}
Батчинг по 50 строк, параллелизация, паузы между запросами.
4. convert-t-to-getters.mjs
Что делает: Фиксит проблему раннего вызова t() в константах.
// Проблема: вызывается при импорте, i18n ещё не готов
export const STATUSES = { active: t('key') }; // ❌
// Решение: lazy evaluation
export const STATUSES = { get active() { return t('key'); } }; // ✅
Скрипт автоматически находит такие паттерны и заменяет.
Грабли
JSX-атрибуты —
title="текст"надо менять наtitle={t('key')}, не забыть фигурные скобкиШаблонные строки —
Привет, ${name}превращается вt('key', { name })Google rate limiting — банит при частых запросах, нужны паузы
alt в img — сначала пропускал как технический, но его надо переводить
Дубликаты — "Сохранить" встречается 50 раз, нужна проверка на уникальность ключа
Минусы
После смены языка нужна перезагрузка страницы
Автопереводы не идеальны — 80% ок, 20% надо вычитывать
Ключи через транслитерацию некрасивые (
sohranit_izmeneniyaвместоbuttons.save)
Скрипты
Забирайте, адаптируйте под себя. Если есть идеи как улучшить или знаете готовые инструменты которые делают то же самое пишите в комментарии.
Скрипты
#!/usr/bin/env node
/**
* Скрипт для преобразования t() вызовов в getter-ы
*
* Проблема: t() вызывается при инициализации модуля, когда i18n ещё не готов
* Решение: Заменить `name: t('key')` на `get name() { return t('key'); }`
*
* Паттерны:
* 1. name: t('key') → get name() { return t('key'); }
* 2. ru: t('key') → get ru() { return t('key'); }
* 3. ['KEY']: t('key') → get ['KEY']() { return t('key'); } (не поддерживается, пропускаем)
*
* Важно: НЕ трогаем t() внутри функций - там уже ленивое выполнение
*
* Опции:
* --dry-run - не сохранять изменения (только показать)
* --file=path - обработать только указанный файл
*
* Примеры:
* node scripts/convert-t-to-getters.mjs --dry-run
* node scripts/convert-t-to-getters.mjs
* node scripts/convert-t-to-getters.mjs --file=src/constants/accountTypes.ts
*/
import fg from 'fast-glob';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default || _traverse;
const generate = _generate.default || _generate;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
// Аргументы
const args = process.argv.slice(2);
function getArg(name, fallback) {
const found = args.find((a) => a.startsWith(`--${name}=`));
if (found) return found.split('=')[1];
if (args.includes(`--${name}`)) return true;
return fallback;
}
const DRY_RUN = getArg('dry-run', false);
const SINGLE_FILE = getArg('file', null);
const CONSTANTS_DIR = 'src/constants';
// Статистика
let stats = {
filesProcessed: 0,
filesModified: 0,
propertiesConverted: 0,
skippedInFunctions: 0,
errors: [],
};
/**
* Проверяет, находится ли узел внутри функции
*/
function isInsideFunction(path) {
let current = path.parentPath;
while (current) {
if (
t.isFunctionDeclaration(current.node) ||
t.isFunctionExpression(current.node) ||
t.isArrowFunctionExpression(current.node) ||
t.isObjectMethod(current.node)
) {
return true;
}
current = current.parentPath;
}
return false;
}
/**
* Проверяет, является ли узел вызовом t()
*/
function isTCall(node) {
return t.isCallExpression(node) && t.isIdentifier(node.callee, { name: 't' });
}
/**
* Преобразует файл
*/
function processFile(filePath) {
const absolutePath = path.resolve(root, filePath);
const code = fs.readFileSync(absolutePath, 'utf-8');
let ast;
try {
ast = parse(code, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
});
} catch (e) {
stats.errors.push({ file: filePath, error: e.message });
return null;
}
let modified = false;
const conversions = [];
traverse(ast, {
ObjectProperty(path) {
// Пропускаем если внутри функции
if (isInsideFunction(path)) {
if (isTCall(path.node.value)) {
stats.skippedInFunctions++;
}
return;
}
// Проверяем что значение - это t() вызов
if (!isTCall(path.node.value)) {
return;
}
// Получаем имя свойства
const key = path.node.key;
// Пропускаем computed properties типа ['KEY']
if (path.node.computed) {
return;
}
// Имя свойства (identifier или string literal)
let keyNode;
if (t.isIdentifier(key)) {
keyNode = t.identifier(key.name);
} else if (t.isStringLiteral(key)) {
keyNode = t.identifier(key.value);
} else {
return;
}
const tCall = path.node.value;
// Создаём getter: get name() { return t('key'); }
const getter = t.objectMethod('get', keyNode, [], t.blockStatement([t.returnStatement(tCall)]));
// Заменяем property на getter
path.replaceWith(getter);
modified = true;
stats.propertiesConverted++;
conversions.push({
property: t.isIdentifier(key) ? key.name : key.value,
line: path.node.loc?.start?.line,
});
},
});
if (!modified) {
return null;
}
const output = generate(
ast,
{
retainLines: true,
retainFunctionParens: true,
},
code,
);
return {
code: output.code,
conversions,
};
}
/**
* Главная функция
*/
async function main() {
console.log('? Конвертация t() в getter-ы...\n');
if (DRY_RUN) {
console.log('⚠️ Режим dry-run: изменения НЕ будут сохранены\n');
}
// Получаем список файлов
let files;
if (SINGLE_FILE) {
files = [SINGLE_FILE];
} else {
files = await fg(`${CONSTANTS_DIR}/**/*.{ts,tsx}`, {
cwd: root,
ignore: ['**/node_modules/**', '**/*.d.ts'],
});
}
console.log(`? Найдено файлов: ${files.length}\n`);
for (const file of files) {
stats.filesProcessed++;
const result = processFile(file);
if (result) {
stats.filesModified++;
console.log(`✅ ${file}`);
result.conversions.forEach((c) => {
console.log(` └─ ${c.property} (строка ${c.line})`);
});
if (!DRY_RUN) {
const absolutePath = path.resolve(root, file);
fs.writeFileSync(absolutePath, result.code, 'utf-8');
}
}
}
// Итоги
console.log('\n' + '='.repeat(50));
console.log('? Статистика:');
console.log(` Файлов обработано: ${stats.filesProcessed}`);
console.log(` Файлов изменено: ${stats.filesModified}`);
console.log(` Свойств сконвертировано: ${stats.propertiesConverted}`);
console.log(` Пропущено (внутри функций): ${stats.skippedInFunctions}`);
if (stats.errors.length > 0) {
console.log(`\n❌ Ошибки (${stats.errors.length}):`);
stats.errors.forEach((e) => {
console.log(` ${e.file}: ${e.error}`);
});
}
if (DRY_RUN && stats.filesModified > 0) {
console.log('\n? Запустите без --dry-run чтобы применить изменения');
}
}
main().catch(console.error);
#!/usr/bin/env node
/**
* Скрипт для поиска русских строк в коде и замены на i18n
*
* Режимы:
* --mode=report - только отчёт со статистикой (по умолчанию)
* --mode=extract - извлечь в JSON и заменить в коде
* --mode=validate - проверить что все t() ключи существуют в JSON
*
* Опции:
* --dry-run - не сохранять изменения (только показать)
* --file=path - обработать только указанный файл
* --include=pattern - glob паттерн для файлов (по умолчанию: src/**\/*.{ts,tsx,js,jsx})
*
* Примеры:
* node scripts/extract-russian.mjs --mode=report
* node scripts/extract-russian.mjs --mode=report --file=src/components/Button.tsx
* node scripts/extract-russian.mjs --mode=extract --dry-run
* node scripts/extract-russian.mjs --mode=extract
* node scripts/extract-russian.mjs --mode=validate
*/
import fg from 'fast-glob';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from '@babel/parser';
import _traverse from '@babel/traverse';
import _generate from '@babel/generator';
import * as t from '@babel/types';
const traverse = _traverse.default || _traverse;
const generate = _generate.default || _generate;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
// Аргументы
const args = process.argv.slice(2);
function getArg(name, fallback) {
const found = args.find((a) => a.startsWith(`--${name}=`));
if (found) return found.split('=')[1];
if (args.includes(`--${name}`)) return true;
return fallback;
}
const MODE = getArg('mode', 'report'); // report | extract | validate
const DRY_RUN = getArg('dry-run', false);
const INCLUDE = getArg('include', 'src/**/*.{ts,tsx,js,jsx}');
const SINGLE_FILE = getArg('file', null);
const IGNORE = ['**/node_modules/**', '**/*.d.ts', '**/*.test.*', '**/*.spec.*', '**/dist/**', '**/build/**'];
// Функции/методы которые не нужно переводить (отладка, ошибки)
const SKIP_CALLEE_NAMES = new Set([
'console.log',
'console.warn',
'console.error',
'console.info',
'console.debug',
'console.trace',
'Error',
'TypeError',
'RangeError',
'SyntaxError',
'ReferenceError',
]);
// JSX атрибуты которые не нужно переводить (технические)
const SKIP_JSX_ATTRIBUTES = new Set([
'className',
'class',
'id',
'name',
'type',
'href',
'src',
'alt', // alt нужно переводить, но часто там технические строки
'data-testid',
'data-cy',
'data-test',
'htmlFor',
'key',
'ref',
'style',
'target',
'rel',
'role',
'tabIndex',
'autoComplete',
'inputMode',
'pattern',
]);
// Регулярка для русских символов
const RUSSIAN_REGEX = /[а-яёА-ЯЁ]/;
// Проверка на русский текст
function hasRussian(str) {
return typeof str === 'string' && RUSSIAN_REGEX.test(str);
}
// Генерация уникального ключа из русского текста
function generateKey(text, category = 'extracted') {
// Убираем лишние символы, берём первые слова
const cleaned = text
.replace(/[^а-яёА-ЯЁa-zA-Z0-9\s]/g, '')
.trim()
.toLowerCase();
// Транслитерация
const translit = transliterate(cleaned);
// Берём первые 3-4 слова, делаем snake_case
const words = translit.split(/\s+/).filter(Boolean).slice(0, 4);
let baseKey = words.join('_').substring(0, 50) || 'text';
let fullKey = `${category}.${baseKey}`;
// Гарантируем уникальность ключа
let counter = 1;
while (usedKeys.has(fullKey) && translations[fullKey] !== text) {
fullKey = `${category}.${baseKey}_${counter}`;
counter++;
}
usedKeys.add(fullKey);
return fullKey;
}
// Простая транслитерация
function transliterate(str) {
const map = {
а: 'a',
б: 'b',
в: 'v',
г: 'g',
д: 'd',
е: 'e',
ё: 'yo',
ж: 'zh',
з: 'z',
и: 'i',
й: 'y',
к: 'k',
л: 'l',
м: 'm',
н: 'n',
о: 'o',
п: 'p',
р: 'r',
с: 's',
т: 't',
у: 'u',
ф: 'f',
х: 'h',
ц: 'ts',
ч: 'ch',
ш: 'sh',
щ: 'sch',
ъ: '',
ы: 'y',
ь: '',
э: 'e',
ю: 'yu',
я: 'ya',
};
return str
.split('')
.map((c) => (c in map ? map[c] : c))
.join('');
}
// Парсинг файла
function parseFile(code, filename) {
return parse(code, {
sourceType: 'module',
sourceFilename: filename,
plugins: [
'jsx',
'typescript',
'classProperties',
'classPrivateProperties',
'decorators-legacy',
'dynamicImport',
'optionalChaining',
'nullishCoalescingOperator',
],
errorRecovery: true,
});
}
// Результаты
const found = []; // { file, line, text, key, context }
const translations = {}; // key -> russian text
const usedKeys = new Set(); // для отслеживания дубликатов
// Загружаем существующие переводы чтобы не перезаписывать
const localesPath = path.join(root, 'public', 'locales', 'ru', 'translation.json');
let existingTranslations = {};
try {
const existing = JSON.parse(fs.readFileSync(localesPath, 'utf8'));
existingTranslations = existing.extracted || {};
// Добавляем существующие ключи в usedKeys
for (const key of Object.keys(existingTranslations)) {
usedKeys.add(`extracted.${key}`);
translations[`extracted.${key}`] = existingTranslations[key];
}
} catch {
// Файл не существует или невалидный JSON - начинаем с пустого
}
// Проверка: уже обёрнуто в t() ?
function isInsideTCall(path) {
let parent = path.parentPath;
while (parent) {
if (parent.isCallExpression() && parent.node.callee?.name === 't') {
return true;
}
parent = parent.parentPath;
}
return false;
}
// Получить имя вызываемой функции (console.log, Error, etc.)
function getCalleeName(callExpr) {
const callee = callExpr.callee;
if (!callee) return null;
// Простой вызов: Error("...")
if (callee.type === 'Identifier') {
return callee.name;
}
// Вызов метода: console.log("...")
if (callee.type === 'MemberExpression') {
const obj = callee.object;
const prop = callee.property;
if (obj?.type === 'Identifier' && prop?.type === 'Identifier') {
return `${obj.name}.${prop.name}`;
}
}
// new Error("...")
if (callExpr.type === 'NewExpression' && callee.type === 'Identifier') {
return callee.name;
}
return null;
}
// Проверка: внутри console.log/Error/throw ?
function isInsideSkippedCall(nodePath) {
let parent = nodePath.parentPath;
while (parent) {
if (parent.isCallExpression() || parent.isNewExpression()) {
const calleeName = getCalleeName(parent.node);
if (calleeName && SKIP_CALLEE_NAMES.has(calleeName)) {
return true;
}
}
// throw new Error("...")
if (parent.isThrowStatement()) {
return true;
}
parent = parent.parentPath;
}
return false;
}
// Проверка: технический JSX атрибут?
function isSkippedJSXAttribute(nodePath) {
if (!nodePath.parentPath?.isJSXAttribute()) return false;
const attrName = nodePath.parentPath.node.name?.name;
return attrName && SKIP_JSX_ATTRIBUTES.has(attrName);
}
// Обработка файла
function processFile(filePath) {
const code = fs.readFileSync(filePath, 'utf8');
const relPath = path.relative(root, filePath);
let ast;
try {
ast = parseFile(code, relPath);
} catch (e) {
console.warn(`⚠ Ошибка парсинга ${relPath}: ${e.message}`);
return { modified: false };
}
let modified = false;
// Ищем русские строки
traverse(ast, {
// Строковые литералы
StringLiteral(p) {
const value = p.node.value;
if (!hasRussian(value)) return;
if (isInsideTCall(p)) return;
// Пропускаем console.log/Error/throw
if (isInsideSkippedCall(p)) return;
// Пропускаем технические JSX атрибуты (className, id, etc.)
if (isSkippedJSXAttribute(p)) return;
// Пропускаем импорты
if (p.parentPath.isImportDeclaration()) return;
// Пропускаем ключи объектов (не значения)
if (p.parentPath.isObjectProperty() && p.parentPath.node.key === p.node) return;
// Пропускаем TypeScript типы
if (p.parentPath.isTSLiteralType()) return;
if (p.parentPath.isTSEnumMember()) return;
// Пропускаем экспорты/импорты
if (p.parentPath.isExportNamedDeclaration()) return;
if (p.parentPath.isExportAllDeclaration()) return;
if (p.parentPath.isImportDeclaration()) return;
// Пропускаем вызовы require и import
if (p.parentPath.isCallExpression() && ['require', 'import'].includes(p.parentPath.node.callee?.name)) return;
// Пропускаем ключи в switch/case
if (p.parentPath.isSwitchCase()) return;
const line = p.node.loc?.start?.line || 0;
const key = generateKey(value);
found.push({
file: relPath,
line,
text: value,
key,
type: 'StringLiteral',
});
if (MODE === 'extract') {
translations[key] = value;
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(key)]);
// Для JSX атрибутов нужно обернуть в JSXExpressionContainer
if (p.parentPath.isJSXAttribute()) {
p.replaceWith(t.jsxExpressionContainer(tCall));
} else {
p.replaceWith(tCall);
}
modified = true;
}
},
// Шаблонные строки
TemplateLiteral(p) {
if (isInsideTCall(p)) return;
if (isInsideSkippedCall(p)) return;
const quasis = p.node.quasis;
const expressions = p.node.expressions;
// Собираем полный текст для проверки на русский
const fullText = quasis.map((q) => q.value.raw).join('{{}}');
if (!hasRussian(fullText)) return;
const line = p.node.loc?.start?.line || 0;
// Простой шаблон без выражений
if (expressions.length === 0) {
const value = quasis[0]?.value?.raw;
const key = generateKey(value);
found.push({
file: relPath,
line,
text: value,
key,
type: 'TemplateLiteral',
});
if (MODE === 'extract') {
translations[key] = value;
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(key)]);
p.replaceWith(tCall);
modified = true;
}
return;
}
// Шаблон с интерполяциями: `Привет, ${name}!` -> t('key', { name })
const interpolations = {};
let translationText = '';
for (let i = 0; i < quasis.length; i++) {
translationText += quasis[i].value.raw;
if (i < expressions.length) {
const expr = expressions[i];
// Используем имя переменной если это идентификатор, иначе arg0, arg1...
const paramName = expr.type === 'Identifier' ? expr.name : `arg${i}`;
interpolations[paramName] = expr;
translationText += `{{${paramName}}}`;
}
}
const key = generateKey(translationText);
found.push({
file: relPath,
line,
text: translationText,
key,
type: 'TemplateLiteralWithExpressions',
interpolations: Object.keys(interpolations),
});
if (MODE === 'extract') {
translations[key] = translationText;
// Создаём объект с интерполяциями: { name: name, count: count }
const objectProps = Object.entries(interpolations).map(([name, expr]) =>
t.objectProperty(
t.identifier(name),
expr,
false,
expr.type === 'Identifier' && expr.name === name, // shorthand
),
);
const tCall = t.callExpression(t.identifier('t'), [t.stringLiteral(key), t.objectExpression(objectProps)]);
p.replaceWith(tCall);
modified = true;
}
},
// JSX текст между тегами
JSXText(p) {
const value = p.node.value.trim();
if (!hasRussian(value)) return;
const line = p.node.loc?.start?.line || 0;
const key = generateKey(value);
found.push({
file: relPath,
line,
text: value,
key,
type: 'JSXText',
});
if (MODE === 'extract') {
translations[key] = value;
// Заменяем текст на {t('key')}
p.replaceWith(t.jsxExpressionContainer(t.callExpression(t.identifier('t'), [t.stringLiteral(key)])));
modified = true;
}
},
});
if (modified && MODE === 'extract' && !DRY_RUN) {
const output = generate(ast, { retainLines: true }, code);
fs.writeFileSync(filePath, output.code, 'utf8');
}
return { modified };
}
// Валидация: проверка что все t() ключи существуют
function validateFile(filePath, allTranslations) {
const code = fs.readFileSync(filePath, 'utf8');
const relPath = path.relative(root, filePath);
const missing = [];
let ast;
try {
ast = parseFile(code, relPath);
} catch (e) {
console.warn(`⚠ Ошибка парсинга ${relPath}: ${e.message}`);
return { missing: [] };
}
traverse(ast, {
CallExpression(p) {
const callee = p.node.callee;
if (callee?.name !== 't') return;
const firstArg = p.node.arguments[0];
if (!firstArg || firstArg.type !== 'StringLiteral') return;
const key = firstArg.value;
// Проверяем существование ключа (поддерживаем вложенные ключи)
const keyExists = getNestedValue(allTranslations, key) !== undefined;
if (!keyExists) {
missing.push({
file: relPath,
line: p.node.loc?.start?.line || 0,
key,
});
}
},
});
return { missing };
}
// Получить значение по вложенному ключу (a.b.c)
function getNestedValue(obj, key) {
const parts = key.split('.');
let current = obj;
for (const part of parts) {
if (current === undefined || current === null) return undefined;
current = current[part];
}
return current;
}
// Статистика
function printStatistics(foundItems) {
const byType = {};
const byFile = {};
for (const item of foundItems) {
byType[item.type] = (byType[item.type] || 0) + 1;
byFile[item.file] = (byFile[item.file] || 0) + 1;
}
console.log('\n? Статистика:');
console.log('─'.repeat(40));
console.log('\nПо типу:');
for (const [type, count] of Object.entries(byType).sort((a, b) => b[1] - a[1])) {
const label =
{
StringLiteral: 'Строки',
TemplateLiteral: 'Шаблоны',
TemplateLiteralWithExpressions: 'Шаблоны с переменными',
JSXText: 'JSX текст',
}[type] || type;
console.log(` ${label}: ${count}`);
}
console.log('\nТоп файлов:');
const sortedFiles = Object.entries(byFile)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
for (const [file, count] of sortedFiles) {
console.log(` ${count.toString().padStart(4)} │ ${file}`);
}
if (Object.keys(byFile).length > 10) {
console.log(` ... и ещё ${Object.keys(byFile).length - 10} файлов`);
}
console.log('─'.repeat(40));
}
// Главная логика
async function main() {
console.log(`=== Поиск русских строк ===`);
console.log(`Режим: ${MODE}${DRY_RUN ? ' (dry-run)' : ''}`);
// Получаем список файлов
let files;
if (SINGLE_FILE) {
const absolutePath = path.isAbsolute(SINGLE_FILE) ? SINGLE_FILE : path.join(root, SINGLE_FILE);
if (!fs.existsSync(absolutePath)) {
console.error(`❌ Файл не найден: ${SINGLE_FILE}`);
process.exit(1);
}
files = [absolutePath];
console.log(`Файл: ${SINGLE_FILE}\n`);
} else {
console.log(`Паттерн: ${INCLUDE}\n`);
files = await fg(INCLUDE, {
cwd: root,
absolute: true,
ignore: IGNORE,
});
}
console.log(`Найдено файлов: ${files.length}\n`);
// Режим валидации
if (MODE === 'validate') {
console.log('Проверка t() ключей...\n');
// Загружаем все переводы
let allTranslations = {};
try {
allTranslations = JSON.parse(fs.readFileSync(localesPath, 'utf8'));
} catch {
console.error(`❌ Не удалось загрузить ${localesPath}`);
process.exit(1);
}
const allMissing = [];
for (const file of files) {
const result = validateFile(file, allTranslations);
allMissing.push(...result.missing);
}
if (allMissing.length === 0) {
console.log('✅ Все ключи найдены в переводах!');
} else {
console.log(`❌ Найдено ${allMissing.length} отсутствующих ключей:\n`);
// Группируем по файлам
const byFile = {};
for (const item of allMissing) {
if (!byFile[item.file]) byFile[item.file] = [];
byFile[item.file].push(item);
}
for (const [file, items] of Object.entries(byFile)) {
console.log(`? ${file}`);
for (const item of items) {
console.log(` L${item.line}: t('${item.key}')`);
}
}
process.exit(1);
}
console.log('\n✓ Готово!');
return;
}
const modifiedFiles = [];
for (const file of files) {
const result = processFile(file);
if (result.modified) {
modifiedFiles.push(path.relative(root, file));
}
}
// Вывод результатов
if (MODE === 'report') {
console.log(`\n=== Найдено русских строк: ${found.length} ===\n`);
// Группируем по файлам
const byFile = {};
for (const item of found) {
if (!byFile[item.file]) byFile[item.file] = [];
byFile[item.file].push(item);
}
for (const [file, items] of Object.entries(byFile)) {
console.log(`\n? ${file}`);
for (const item of items) {
console.log(` L${item.line}: "${item.text.substring(0, 60)}${item.text.length > 60 ? '...' : ''}"`);
console.log(` → ${item.key}`);
}
}
// Статистика
if (found.length > 0) {
printStatistics(found);
}
// Сохраняем отчёт
const reportPath = path.join(root, 'russian-strings-report.json');
fs.writeFileSync(reportPath, JSON.stringify(found, null, 2), 'utf8');
console.log(`\n✓ Отчёт сохранён: ${reportPath}`);
}
if (MODE === 'extract') {
// Сохраняем переводы в JSON
const existing = JSON.parse(fs.readFileSync(localesPath, 'utf8'));
// Добавляем extracted секцию
if (!existing.extracted) existing.extracted = {};
// Считаем только новые переводы (которых не было в existingTranslations)
let newCount = 0;
for (const [key, value] of Object.entries(translations)) {
const parts = key.split('.');
if (parts[0] === 'extracted') {
const shortKey = parts.slice(1).join('.');
if (!existingTranslations[shortKey]) {
newCount++;
}
existing.extracted[shortKey] = value;
}
}
if (!DRY_RUN) {
fs.writeFileSync(localesPath, JSON.stringify(existing, null, 2) + '\n', 'utf8');
console.log(`\n✓ Добавлено ${newCount} новых переводов в ${localesPath}`);
if (Object.keys(existingTranslations).length > 0) {
console.log(` (${Object.keys(existingTranslations).length} уже существовало)`);
}
} else {
console.log(`\n[DRY-RUN] Было бы добавлено ${newCount} новых переводов`);
}
if (modifiedFiles.length > 0) {
console.log(`\n${DRY_RUN ? '[DRY-RUN] Было бы изменено' : 'Изменено'} файлов: ${modifiedFiles.length}`);
for (const f of modifiedFiles.slice(0, 20)) {
console.log(` - ${f}`);
}
if (modifiedFiles.length > 20) {
console.log(` ... и ещё ${modifiedFiles.length - 20}`);
}
}
// Статистика
if (found.length > 0) {
printStatistics(found);
}
}
console.log('\n✓ Готово!');
}
main().catch(console.error);
#!/usr/bin/env node
/**
* Синхронизация структуры JSON файлов локализации
*
* Использование:
* node scripts/sync-locales.mjs
*
* Что делает:
* - Собирает все уникальные ключи из всех языков (ru, en, kk)
* - Добавляет недостающие ключи с пустыми значениями
* - Сортирует ключи по алфавиту
* - Показывает процент заполненности каждого языка
*
* Пример:
* До: ru.json: { a: "А" }, en.json: { b: "B" }
* После: ru.json: { a: "А", b: "" }, en.json: { a: "", b: "B" }
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
// ============================================
// КОНФИГУРАЦИЯ — НАСТРОЙТЕ ПОД СВОЙ ПРОЕКТ
// ============================================
const localesDir = path.join(root, 'public', 'locales');
const LANGUAGES = ['ru', 'en', 'kk'];
// ============================================
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// ============================================
/**
* Глубокое слияние объектов (добавляет ключи из source в target)
*/
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key]);
} else if (!(key in result)) {
result[key] = source[key];
}
}
return result;
}
// Собрать все уникальные ключи из всех языков
function collectAllKeys(objects) {
let merged = {};
for (const obj of objects) {
merged = deepMerge(merged, obj);
}
return merged;
}
// Заполнить пустые значения из исходного объекта (ru)
function fillFromSource(template, source) {
const result = {};
for (const key of Object.keys(template)) {
if (template[key] && typeof template[key] === 'object' && !Array.isArray(template[key])) {
result[key] = fillFromSource(template[key], source?.[key] || {});
} else {
// Берём значение из source (ru), если нет - оставляем из template
result[key] = source?.[key] ?? template[key] ?? '';
}
}
return result;
}
// Создать пустой шаблон (для перевода)
function createEmptyTemplate(template, source) {
const result = {};
for (const key of Object.keys(template)) {
if (template[key] && typeof template[key] === 'object' && !Array.isArray(template[key])) {
result[key] = createEmptyTemplate(template[key], source?.[key] || {});
} else {
// Берём значение из source если есть, иначе пустая строка (для перевода)
result[key] = source?.[key] ?? '';
}
}
return result;
}
// Сортировка ключей объекта рекурсивно
function sortKeys(obj) {
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
return obj;
}
const sorted = {};
for (const key of Object.keys(obj).sort()) {
sorted[key] = sortKeys(obj[key]);
}
return sorted;
}
// Загрузить JSON файл
function loadJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (e) {
console.warn(`Не удалось загрузить ${filePath}: ${e.message}`);
return {};
}
}
// Сохранить JSON файл
function saveJson(filePath, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
}
// Подсчёт ключей
function countKeys(obj, prefix = '') {
let count = 0;
for (const key of Object.keys(obj)) {
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
count += countKeys(obj[key], `${prefix}${key}.`);
} else {
count++;
}
}
return count;
}
// Подсчёт заполненных ключей
function countFilled(obj) {
let count = 0;
for (const key of Object.keys(obj)) {
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
count += countFilled(obj[key]);
} else if (obj[key] && obj[key].trim() !== '') {
count++;
}
}
return count;
}
console.log('=== Синхронизация локалей ===\n');
// Загружаем все файлы
const translations = {};
for (const lang of LANGUAGES) {
const filePath = path.join(localesDir, lang, 'translation.json');
translations[lang] = loadJson(filePath);
console.log(`Загружен ${lang}: ${countKeys(translations[lang])} ключей`);
}
// Собираем все ключи из всех языков
const allKeys = collectAllKeys(Object.values(translations));
const totalKeys = countKeys(allKeys);
console.log(`\nВсего уникальных ключей: ${totalKeys}`);
// Создаём результат: ru содержит все значения, en и kk - существующие или пустые
const result = {};
// Для ru: заполняем все ключи значениями из ru
result.ru = sortKeys(fillFromSource(allKeys, translations.ru));
// Для en и kk: берём существующие переводы или пустые строки
result.en = sortKeys(createEmptyTemplate(allKeys, translations.en));
result.kk = sortKeys(createEmptyTemplate(allKeys, translations.kk));
// Сохраняем
for (const lang of LANGUAGES) {
const filePath = path.join(localesDir, lang, 'translation.json');
saveJson(filePath, result[lang]);
const filled = countFilled(result[lang]);
const percent = totalKeys > 0 ? Math.round((filled / totalKeys) * 100) : 0;
console.log(`Сохранён ${lang}: ${filled}/${totalKeys} заполнено (${percent}%)`);
}
console.log('\n✓ Готово! Теперь можно переводить en и kk файлы.');
#!/usr/bin/env node
/**
* Автоматический перевод пустых строк через DeepL и Google Translate
*
* Использование:
* node scripts/translate-locales.mjs
*
* Перед запуском:
* 1. Получите API ключ DeepL: https://www.deepl.com/pro-api
* 2. Вставьте ключ в DEEPL_API_KEY ниже
* 3. Настройте LOCALES_PATH под свой проект
*
* Что делает:
* - Находит пустые строки в en.json и kk.json
* - Переводит EN через DeepL (лучшее качество)
* - Переводит KK через Google Translate (DeepL не поддерживает казахский)
* - Батчинг по 50 строк, параллелизация, паузы между запросами
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ============================================
// КОНФИГУРАЦИЯ — НАСТРОЙТЕ ПОД СВОЙ ПРОЕКТ
// ============================================
// DeepL API ключ (получить: https://www.deepl.com/pro-api)
const DEEPL_API_KEY = process.env.DEEPL_API_KEY || '';
// Для бесплатного API используйте api-free.deepl.com, для платного — api.deepl.com
const DEEPL_API_URL = 'https://api-free.deepl.com/v2/translate';
// Путь к папке с локалями
const LOCALES_PATH = path.join(__dirname, '../public/locales');
// ============================================
// ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
// ============================================
/**
* Рекурсивно находит все пустые строки в объекте переводов
* @param {Object} obj - объект переводов (en или kk)
* @param {Object} ruObj - русский объект для получения исходного текста
* @param {string} prefix - текущий путь ключа (для вложенных объектов)
* @returns {Array} - массив { key, ruValue }
*/
function getEmptyStrings(obj, ruObj, prefix = '') {
const result = [];
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
const ruValue = ruObj?.[key];
if (typeof value === 'object' && value !== null) {
result.push(...getEmptyStrings(value, ruValue, fullKey));
} else if (value === '' && ruValue && typeof ruValue === 'string' && ruValue !== '') {
result.push({ key: fullKey, ruValue });
}
}
return result;
}
// Устанавливаем значение по пути
function setValueByPath(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (!current[keys[i]]) {
current[keys[i]] = {};
}
current = current[keys[i]];
}
current[keys[keys.length - 1]] = value;
}
// Переводим через DeepL
async function translateWithDeepL(texts, targetLang) {
const batchSize = 50;
const results = [];
for (let i = 0; i < texts.length; i += batchSize) {
const batch = texts.slice(i, i + batchSize);
console.log(
`Translating batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(texts.length / batchSize)} (${batch.length} texts) to ${targetLang}...`,
);
const response = await fetch(DEEPL_API_URL, {
method: 'POST',
headers: {
Authorization: `DeepL-Auth-Key ${DEEPL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: batch,
source_lang: 'RU',
target_lang: targetLang,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`DeepL API error: ${response.status} - ${error}`);
}
const data = await response.json();
results.push(...data.translations.map((t) => t.text));
// Задержка между запросами
if (i + batchSize < texts.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
return results;
}
// Переводим один текст через Google Translate
async function translateSingleGoogle(text, targetLang) {
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=ru&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// data[0] содержит массив чанков перевода
let result = '';
if (data[0]) {
for (const chunk of data[0]) {
if (chunk && typeof chunk[0] === 'string') {
result += chunk[0];
}
}
}
return result || text;
}
// Переводим батч текстов параллельно
async function translateBatchGoogle(batch, targetLang, batchIndex, totalBatches) {
console.log(`Translating batch ${batchIndex + 1}/${totalBatches} (${batch.length} texts) to ${targetLang}...`);
const promises = batch.map(async (text, idx) => {
try {
return await translateSingleGoogle(text, targetLang);
} catch (e) {
console.error(`Error translating item ${idx} in batch ${batchIndex + 1}:`, e?.message);
return text; // fallback к оригиналу
}
});
const results = await Promise.all(promises);
console.log(`Batch ${batchIndex + 1}/${totalBatches} done!`);
return results;
}
// Переводим через Google Translate (параллельно)
async function translateWithGoogle(texts, targetLang) {
const batchSize = 50;
const concurrency = 5; // количество параллельных запросов
const batches = [];
// Разбиваем на батчи
for (let i = 0; i < texts.length; i += batchSize) {
batches.push(texts.slice(i, i + batchSize));
}
const totalBatches = batches.length;
console.log(`Total batches: ${totalBatches}, concurrency: ${concurrency}`);
const results = new Array(totalBatches);
// Обрабатываем батчи параллельно с ограничением concurrency
for (let i = 0; i < totalBatches; i += concurrency) {
const chunk = batches.slice(i, i + concurrency);
const promises = chunk.map((batch, idx) => translateBatchGoogle(batch, targetLang, i + idx, totalBatches));
const chunkResults = await Promise.all(promises);
chunkResults.forEach((res, idx) => {
results[i + idx] = res;
});
// Небольшая пауза между группами параллельных запросов
if (i + concurrency < totalBatches) {
await new Promise((r) => setTimeout(r, 100));
}
}
return results.flat();
}
async function main() {
console.log('Loading translation files...');
const ruJson = JSON.parse(fs.readFileSync(path.join(LOCALES_PATH, 'ru/translation.json'), 'utf-8'));
const enJson = JSON.parse(fs.readFileSync(path.join(LOCALES_PATH, 'en/translation.json'), 'utf-8'));
const kkJson = JSON.parse(fs.readFileSync(path.join(LOCALES_PATH, 'kk/translation.json'), 'utf-8'));
// Находим пустые строки
const emptyEN = getEmptyStrings(enJson, ruJson);
const emptyKK = getEmptyStrings(kkJson, ruJson);
console.log(`Found ${emptyEN.length} empty strings in EN`);
console.log(`Found ${emptyKK.length} empty strings in KK`);
// Переводим EN через DeepL
if (emptyEN.length > 0) {
console.log('\n--- Translating to English (DeepL) ---');
const textsToTranslate = emptyEN.map((item) => item.ruValue);
const translatedEN = await translateWithDeepL(textsToTranslate, 'EN');
for (let i = 0; i < emptyEN.length; i++) {
setValueByPath(enJson, emptyEN[i].key, translatedEN[i]);
}
fs.writeFileSync(path.join(LOCALES_PATH, 'en/translation.json'), JSON.stringify(enJson, null, 2), 'utf-8');
console.log('EN translations saved!');
}
// Переводим KK через Google Translate
if (emptyKK.length > 0) {
console.log('\n--- Translating to Kazakh (Google Translate) ---');
const textsToTranslate = emptyKK.map((item) => item.ruValue);
const translatedKK = await translateWithGoogle(textsToTranslate, 'kk');
for (let i = 0; i < emptyKK.length; i++) {
setValueByPath(kkJson, emptyKK[i].key, translatedKK[i]);
}
fs.writeFileSync(path.join(LOCALES_PATH, 'kk/translation.json'), JSON.stringify(kkJson, null, 2), 'utf-8');
console.log('KK translations saved!');
}
console.log('\nDone!');
}
main().catch(console.error);
GitHub: github.com/Niyaz-Mazhitov
Комментарии (4)

FrodoB
21.12.2025 12:48А не думал потом автоматом маппить ключи на более читаемую структуру?

niyaz_kz Автор
21.12.2025 12:48Думал, конечно) Сейчас приоритет был быстро убрать хардкод и получить рабочую i18n. В следующей версии планирую улучшить скрипты и как раз поработать над более читаемой и осмысленной структурой ключей, об этом будет вторая часть статьи)
Emelian
Напомнило мою задачу, когда ЛНР вышел из юрисдикции Украины, но еще не вошел в состав РФ.
«Бизнес» срочно «захотел» перевода украинской бухгалтерии и производственного учета на русский язык.
Помимо, собственно перевода «внутренностей» соответствующих конфигураций «1С» с украинского на русский, нужно было еще гривны адаптировать в рубли плюс учесть разного рода бухгалтерские нюансы. И все это надо было сделать быстро. Напрягся и сделал.
При этом, План Счетов остался украинским, только был переведен на русский язык. Параллельные ФИО сотрудников остались на прежнем языке, только я их перестал использовать, а все отчеты переориентировал на русскоязычные ФИО.
Когда ЛНР вошла в состав РФ, буквально сразу «бизнес» потребовал перевести туже бухгалтерию и иже с ней на законодательство России. С «зарплатой» и учетом рабочего времени, было немного проще это были 100%-но мои конфигурации (а, также, сама техническая система на базе нетбуков, считывателей персональных RFID-карт доступа сотрудников и собственного драйвера обработки данных на С++), внутренне, всегда были русскоязычными, украиноязычной была только внешняя отчетность. Поэтому, адаптировать алгоритмы украинские в российские – особых проблем не составило, просто, заняло некоторое время.
Сложнее было с переводом украинского Плана Счетов в русский, включая перепроведение всех документов. Это головомойка была еще та!.
Главная проблема установление соответствия между бухгалтерскими счетами. Я ведь не бухгалтер, а наши бухгалтера не могли и не хотели проводить документы с нуля, в новом Плане Счетов, за большой период. Тем более, что и российской бухгалтерии из них никто толком не знал. Даже главбух жаловался, что у него на столе лежит 300 законов РФ, которые он должен был знать еще вчера.
Поэтому, наваял промежуточную конфигурацию, которая позволяла работать с двумя Планами Счетом и делать перепроваедние документов, в зависимости от выбранного соответствия. Иначе говоря, если соответствие было выбрано неправильно, можно было его отменить, откатится назад, исправить и перепровести заново.
Короче говоря, бухгалтер мог экспериментировать и получать нужный ему результат.
Постепенно, процесс налаживался, мы осваивали новые российские внешние отчеты и инструменты работы с ними, включая, электронные больничные и электронные трудовые книжки. Немного жаль, что нас «подстрелили» на взлете. Предприятие закрыли по политическим мотивам, вроде как, наша продукция была неконкурентоспособна. Да и, новый собственник начал замещать мою учетную систему своей, так что уход мой из фирмы – стал предсказуемым.
niyaz_kz Автор
Спасибо, что поделились опытом. У вас это был куда более жёсткий сценарий, смена законодательства и планов счетов по сравнению с локализацией фронта выглядит совсем другим уровнем сложности)