Apps, web, i18n, UI, partners, payouts, backoffice, bariki, prod infra

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-25 22:55:29 +04:00
1 parent 45d7512d2b
commit 3489e0b816
319 files changed
+45483 -121

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@barev/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./locales/*.json": "./locales/*.json"
},
"scripts": {
"check": "tsx scripts/check.ts",
"export": "tsx scripts/export.ts",
"import": "tsx scripts/import.ts",
"test": "tsx scripts/check.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"tsx": "^4.20.0",
"@barev/core": "workspace:*"
}
}
+1
View File
@@ -0,0 +1 @@
{ "en": {}, "zh": {}, "hy": {}, "es": {} }
+62
View File
@@ -0,0 +1,62 @@
/**
* Проверка переводов (критерий приёмки «нет ключей без перевода»):
* - в каждом языке есть все ключи 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: все ключи переведены.');
+37
View File
@@ -0,0 +1,37 @@
/**
* Ключи, которые строятся динамически из домена (статусы сценариев, кнопки, чек-листы,
* параметры услуг, районы, роли). Используется в check.ts.
*/
import { CATALOG, FLOWS, flowStatuses, PROVIDER_ROLES, SERVICE_FLOWS, SETTING_KEYS, YEREVAN_DISTRICTS } from '@barev/core';
export function dynamicKeys(): string[] {
const keys: string[] = [];
for (const flow of SERVICE_FLOWS) {
for (const s of flowStatuses(flow)) keys.push(`status.${flow}.${s}`, `notify.status.${flow}.${s}`);
for (const t of FLOWS[flow].transitions) if (t.action) keys.push(`pro.action.${t.action}`, `admin.events.${t.action}`);
for (const e of FLOWS[flow].events) keys.push(`pro.action.${e.code}`, `admin.events.${e.code}`, `notify.event.${e.code}`);
keys.push(`client.detail.providerLabel.${flow}`);
}
for (const s of CATALOG) {
keys.push(`services.${s.code}.name`, `services.${s.code}.description`);
for (const c of s.checklist) keys.push(`checklist.${c.code}`);
if (s.pricing.kind === 'rooms') {
for (const [code, e] of Object.entries(s.pricing.extras)) {
keys.push(`extras.${code}.name`);
if (e.unit !== 'service') keys.push(`extras.${code}.per`);
}
}
for (const f of s.paramsSchema.fields) {
if (f.type === 'extras') continue;
keys.push(`params.${s.code}.${f.key}.label`);
if (f.type === 'select') for (const o of f.options) keys.push(`params.${s.code}.${f.key}.${o}`, `params.${s.code}.${f.key}.${o}_hint`);
}
}
for (const d of YEREVAN_DISTRICTS) keys.push(`districts.${d}`);
for (const r of PROVIDER_ROLES) keys.push(`roles.${r}`);
for (const k of SETTING_KEYS) keys.push(`admin.settings.${k.replace('.', '_')}`);
// приложение: статусы шагов переезда, категории партнёров (названия шагов и документов — в базе, RelocationItem)
for (const s of ['TODO', 'IN_PROGRESS', 'DONE', 'SKIPPED']) keys.push(`client.move.status.${s}`);
for (const c of ['cafe', 'restaurant', 'shop', 'beauty', 'other']) keys.push(`client.partners.cat.${c}`);
return keys;
}
+31
View File
@@ -0,0 +1,31 @@
/**
* Выгрузка для носителей языка: i18n-review/<lang>.csv
* key, ru, translation, status, approved
* Носитель правит колонку translation и ставит "yes" в approved, затем: pnpm i18n:import <файл>.
*
* pnpm i18n:export — все языки, только needs_review
* pnpm i18n:export -- --all — все ключи
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { csvEscape, readLocale, readReview, ROOT, statusOf, TARGETS } from './lib.ts';
const all = process.argv.includes('--all');
const ru = readLocale('ru');
const review = readReview();
const outDir = path.resolve(ROOT, '../../i18n-review');
mkdirSync(outDir, { recursive: true });
for (const locale of TARGETS) {
const msgs = readLocale(locale);
const rows = [['key', 'ru', 'translation', 'status', 'approved']];
for (const [key, source] of Object.entries(ru)) {
const status = statusOf(review, locale, key, source, msgs[key]);
if (!all && status === 'approved') continue;
rows.push([key, source, msgs[key] ?? '', status, '']);
}
const file = path.join(outDir, `${locale}.csv`);
// BOM — чтобы Excel открыл UTF-8 с армянским и китайским
writeFileSync(file, '' + rows.map((r) => r.map(csvEscape).join(',')).join('\n') + '\n');
console.log(`${file}: ${rows.length - 1} строк`);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Загрузка проверенных переводов: pnpm i18n:import -- i18n-review/hy.csv [i18n-review/zh.csv …]
* Язык берётся из имени файла. Строки с approved=yes помечаются проверенными для текущего русского текста.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { isLocale } from '../src/index.ts';
import { parseCsv, placeholders, readLocale, readReview, sourceHash, writeLocale, writeReview } from './lib.ts';
const files = process.argv.slice(2).filter((a) => !a.startsWith('--'));
if (!files.length) {
console.error('Укажите CSV-файлы: pnpm i18n:import -- i18n-review/hy.csv');
process.exit(1);
}
const ru = readLocale('ru');
const order = Object.keys(ru);
const review = readReview();
for (const file of files) {
const locale = path.basename(file, '.csv');
if (!isLocale(locale) || locale === 'ru') throw new Error(`Не понимаю язык файла ${file}`);
const [header, ...rows] = parseCsv(readFileSync(path.resolve(process.env.INIT_CWD ?? process.cwd(), file), 'utf8').replace(/^\uFEFF/, ''));
const col = (name: string) => header!.indexOf(name);
const msgs = readLocale(locale);
let updated = 0;
let approved = 0;
for (const r of rows) {
const key = r[col('key')]!;
const translation = r[col('translation')]?.trim();
if (!(key in ru) || !translation) continue;
if (placeholders(ru[key]!) !== placeholders(translation)) {
console.warn(`${locale}: пропущен ${key} — плейсхолдеры не совпадают с русским`);
continue;
}
if (msgs[key] !== translation) updated++;
msgs[key] = translation;
if (/^(yes|да|y|1|ok)$/i.test(r[col('approved')]?.trim() ?? '')) {
review[locale][key] = sourceHash(ru[key]!);
approved++;
}
}
writeLocale(locale, msgs, order);
console.log(`${locale}: обновлено ${updated}, проверено ${approved}`);
}
writeReview(review);
+79
View File
@@ -0,0 +1,79 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { flatten, LOCALES, type Locale, type Messages } from '../src/index.ts';
export const ROOT = path.resolve(import.meta.dirname, '..');
export const TARGETS = LOCALES.filter((l) => l !== 'ru') as Exclude<Locale, 'ru'>[];
type Tree = { [k: string]: string | Tree };
export function readLocale(locale: Locale): Messages {
return flatten(JSON.parse(readFileSync(path.join(ROOT, 'locales', `${locale}.json`), 'utf8')) as Tree);
}
export function writeLocale(locale: Locale, flat: Messages, order: string[]) {
const tree: Tree = {};
for (const key of order) {
if (flat[key] === undefined) continue;
const parts = key.split('.');
let node = tree;
for (const p of parts.slice(0, -1)) node = (node[p] ??= {}) as Tree;
node[parts.at(-1)!] = flat[key]!;
}
writeFileSync(path.join(ROOT, 'locales', `${locale}.json`), JSON.stringify(tree, null, 2) + '\n');
}
/**
* review.json хранит для каждого языка { ключ: хеш русского текста на момент проверки }.
* Если русский текст изменился, хеш не совпадёт — ключ снова needs_review.
*/
export type Review = Record<Exclude<Locale, 'ru'>, Record<string, string>>;
export const readReview = (): Review => JSON.parse(readFileSync(path.join(ROOT, 'review.json'), 'utf8'));
export const writeReview = (r: Review) => writeFileSync(path.join(ROOT, 'review.json'), JSON.stringify(r, null, 2) + '\n');
export const sourceHash = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12);
export type Status = 'approved' | 'needs_review' | 'missing';
export function statusOf(review: Review, locale: Exclude<Locale, 'ru'>, key: string, ru: string, value: string | undefined): Status {
if (value === undefined || value === '') return 'missing';
return review[locale][key] === sourceHash(ru) ? 'approved' : 'needs_review';
}
export const placeholders = (s: string) => [...s.matchAll(/\{\{\s*(\w+)\s*\}\}/g)].map((m) => m[1]).sort().join(',');
export function csvEscape(v: string) {
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
}
export function parseCsv(text: string): string[][] {
const rows: string[][] = [];
let row: string[] = [];
let cell = '';
let quoted = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (quoted) {
if (ch === '"' && text[i + 1] === '"') {
cell += '"';
i++;
} else if (ch === '"') quoted = false;
else cell += ch;
} else if (ch === '"') quoted = true;
else if (ch === ',') {
row.push(cell);
cell = '';
} else if (ch === '\n' || ch === '\r') {
if (ch === '\r' && text[i + 1] === '\n') i++;
row.push(cell);
rows.push(row);
row = [];
cell = '';
} else cell += ch;
}
if (cell || row.length) {
row.push(cell);
rows.push(row);
}
return rows.filter((r) => r.some((c) => c !== ''));
}
+112
View File
@@ -0,0 +1,112 @@
import ru from '../locales/ru.json' with { type: 'json' };
import en from '../locales/en.json' with { type: 'json' };
import zh from '../locales/zh.json' with { type: 'json' };
import hy from '../locales/hy.json' with { type: 'json' };
import es from '../locales/es.json' with { type: 'json' };
export const LOCALES = ['ru', 'en', 'zh', 'hy', 'es'] as const;
export type Locale = (typeof LOCALES)[number];
export const SOURCE_LOCALE: Locale = 'ru';
/** Подписи языков в переключателе (как в макете) */
export const LOCALE_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN', zh: '中文', hy: 'ՀԱՅ', es: 'ES' };
/** Полное название языка на нём самом */
export const LOCALE_NAMES: Record<Locale, string> = { ru: 'Русский', en: 'English', zh: '中文', hy: 'Հայերեն', es: 'Español' };
/** BCP 47 для Intl */
export const INTL_LOCALE: Record<Locale, string> = { ru: 'ru-RU', en: 'en-GB', zh: 'zh-CN', hy: 'hy-AM', es: 'es-ES' };
type Tree = { [k: string]: string | Tree };
export type Messages = Record<string, string>;
/** {"a":{"b":"x"}} → {"a.b":"x"} */
export function flatten(tree: Tree, prefix = '', out: Messages = {}): Messages {
for (const [k, v] of Object.entries(tree)) {
const key = prefix ? `${prefix}.${k}` : k;
if (typeof v === 'string') out[key] = v;
else flatten(v, key, out);
}
return out;
}
export const resources: Record<Locale, Messages> = {
ru: flatten(ru as Tree),
en: flatten(en as Tree),
zh: flatten(zh as Tree),
hy: flatten(hy as Tree),
es: flatten(es as Tree),
};
export type TParams = Record<string, string | number | null | undefined>;
export type TFunction = (key: string, params?: TParams) => string;
const pluralRules = new Map<Locale, Intl.PluralRules>();
function plural(locale: Locale, n: number): string {
let r = pluralRules.get(locale);
if (!r) pluralRules.set(locale, (r = new Intl.PluralRules(INTL_LOCALE[locale])));
return r.select(n);
}
function interpolate(s: string, params?: TParams) {
if (!params) return s;
return s.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k: string) => String(params[k] ?? ''));
}
/**
* Перевод с фолбэком на русский. Множественное число: ключи `<key>_one`, `_few`, `_many`, `_other`
* выбираются по params.count. Отсутствующий ключ возвращается как есть — его ловит `pnpm i18n:check`.
*/
const merged = new Map<Locale, Messages>();
function withFallback(locale: Locale): Messages {
let m = merged.get(locale);
if (!m) merged.set(locale, (m = { ...resources.ru, ...(resources[locale] ?? {}) }));
return m;
}
export function createT(locale: Locale): TFunction {
return tFromMessages(locale, withFallback(locale));
}
/**
* Подмножество переводов (с фолбэком на русский) для передачи в клиентский компонент:
* сервер отдаёт только нужные префиксы, а не все словари.
*/
export function pickMessages(locale: Locale, prefixes: string[]): Messages {
const out: Messages = {};
for (const [k, v] of Object.entries(withFallback(locale))) if (prefixes.some((p) => k.startsWith(p))) out[k] = v;
return out;
}
/** t() поверх готового набора сообщений — те же правила множественного числа и подстановок, что у createT */
export function tFromMessages(locale: Locale, messages: Messages): TFunction {
return (key, params) => {
let k = key;
if (typeof params?.count === 'number') {
const form = `${key}_${plural(locale, params.count)}`;
if (messages[form]) k = form;
else if (messages[`${key}_other`]) k = `${key}_other`;
}
const s = messages[k];
return s === undefined ? key : interpolate(s, params);
};
}
export function isLocale(v: unknown): v is Locale {
return typeof v === 'string' && (LOCALES as readonly string[]).includes(v);
}
/** Шрифты: Rubik/Onest не содержат армянский и китайский — для них фолбэк */
export const FONT_FALLBACK: Record<Locale, string> = {
ru: '',
en: '',
es: '',
hy: "'Noto Sans Armenian', ",
zh: "'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', ",
};
export function formatDateTime(locale: Locale, date: Date, opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }) {
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { timeZone: 'Asia/Yerevan', ...opts }).format(date);
}
export function formatAmd(locale: Locale, amount: number) {
return `${new Intl.NumberFormat(INTL_LOCALE[locale], { maximumFractionDigits: 0 }).format(amount)} ֏`;
}
+1
View File
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["node"] }, "include": ["src", "scripts"] }