Files
BarevClub/packages/i18n/scripts/check.ts
T
2026-10-25 22:55:29 +04:00

63 lines
3.2 KiB
TypeScript

/**
* Проверка переводов (критерий приёмки «нет ключей без перевода»):
* - в каждом языке есть все ключи ru, без пустых значений и лишних ключей;
* - плейсхолдеры {{x}} совпадают с исходником;
* - отчёт по статусам needs_review / approved.
* Код выхода 1 при любой ошибке — для CI.
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import { dynamicKeys } from './dynamic-keys.ts';
import { placeholders, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const ru = readLocale('ru');
const review = readReview();
const errors: string[] = [];
for (const [key, value] of Object.entries(ru)) {
if (!value.trim()) errors.push(`ru: пустое значение ${key}`);
}
// ключи-литералы в коде (вида t(«a.b»)) должны существовать в ru
const NAMESPACES = Object.keys(JSON.parse(readFileSync(path.join(ROOT, 'locales/ru.json'), 'utf8')) as object);
const KEY_RE = new RegExp(`['\`](?:${NAMESPACES.join('|')})\\.[a-zA-Z0-9_.]+(?=['\`])`, 'g');
function walk(dir: string, out: string[] = []) {
for (const name of readdirSync(dir)) {
if (['node_modules', 'generated', '.next', 'dist', '.turbo'].includes(name)) continue;
const p = path.join(dir, name);
if (statSync(p).isDirectory()) walk(p, out);
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.ts$/.test(name)) out.push(p);
}
return out;
}
const repo = path.resolve(ROOT, '../..');
const pluralBase = (k: string) => k.replace(/_(one|few|many|other)$/, '');
const known = new Set(Object.keys(ru).flatMap((k) => [k, pluralBase(k)]));
for (const file of [...walk(path.join(repo, 'apps')), ...walk(path.join(repo, 'packages'))]) {
for (const m of readFileSync(file, 'utf8').matchAll(KEY_RE)) {
const key = m[0].slice(1);
if (!known.has(key) && !key.endsWith('.')) errors.push(`ключ из кода отсутствует в ru: ${key} (${path.relative(repo, file)})`);
}
}
for (const key of dynamicKeys()) if (!known.has(key)) errors.push(`динамический ключ отсутствует в ru: ${key}`);
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const counts = { approved: 0, needs_review: 0, missing: 0 };
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
counts[status]++;
if (status === 'missing') errors.push(`${locale}: нет перевода ${key}`);
else if (placeholders(source) !== placeholders(msgs[key]!)) errors.push(`${locale}: плейсхолдеры не совпадают в ${key}`);
}
for (const key of Object.keys(msgs)) if (!(key in ru)) errors.push(`${locale}: лишний ключ ${key}`);
console.log(`${locale}: ${Object.keys(ru).length} ключей · approved ${counts.approved} · needs_review ${counts.needs_review} · missing ${counts.missing}`);
}
if (errors.length) {
console.error(`\n${errors.length} ошибок:\n` + errors.slice(0, 50).join('\n'));
process.exit(1);
}
console.log('i18n: все ключи переведены.');