Привет, Хабр! Меня зовут Нияз, frontend тимлид из Казахстана. Это мой первый пост — делюсь скриптами, которые сэкономили неделю работы.
UPD: После публикации несколько человек попросили скрипты. Копировать 4 файла неудобно — оформил в npm-пакет с CLI, конфигом и watch-режимом: Часть 2: i18next-toolkit
Проблема
HR-платформа, 8000+ TypeScript файлов, весь текст захардкожен на русском. Бизнес хочет английский и казахский.
<Button>Сохранить</Button>
<span>Привет, {userName}!</span>
const error = "Произошла ошибка";Руками — это неделя копипасты и сотни пропущенных строк. Решил написать скрипты.
Результат
Метрика | Значение |
Ключей перевода | 9,823 |
Вызовов t() в коде | 39,086 |
Файлов обработано | 8,198 |
Время работы скриптов | ~5 минут |
Пайплайн
EXTRACT → SYNC → TRANSLATE → CONVERT4 скрипта, каждый делает одну задачу.
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