Привет, Хабр! Меня зовут Нияз, 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 → 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
