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:
1 parent
45d7512d2b
commit
3489e0b816
319 files changed
+45483
-121
No files matched your search
@@ -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: все ключи переведены.');
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} строк`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 !== ''));
|
||||
}
|
||||
Reference in new issue
Block a user