/** * Проверка переводов (критерий приёмки «нет ключей без перевода»): * - в каждом языке есть все ключи 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: все ключи переведены.');