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