Русский и английский языки интерфейса
CI / test (push) Successful in 22s
Release / release (push) Successful in 29s

Язык выбирается в шапке панели, а до первого выбора берётся из настроек
браузера. Переведены интерфейс, подписи и описания всех параметров конфигов,
ошибки API и служебные сообщения менеджера в консоли.

Подписи параметров вынесены из app.js в hints-<язык>.json: их больше четырёхсот
на язык, и грузить чужой язык незачем. Сообщения менеджера переводятся на
выходе из API по словарю, где ключом служит русская фраза из кода, — вывод
самого игрового сервера через него проходит без изменений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 21:14:21 +03:00
co-authored by Claude Opus 5
parent d5a308ac8d
commit 9a224bc070
19 changed files with 5417 additions and 857 deletions
+152
View File
@@ -0,0 +1,152 @@
// Package i18n переводит сообщения менеджера на язык веб-панели.
//
// Исходный язык кода — русский, он же служит ключом перевода: словарь связывает
// русскую фразу с английской, а незнакомая фраза остаётся как есть. Благодаря
// этому через перевод можно безопасно пропускать и вывод игрового сервера — он
// английский и в словаре не встретится.
package i18n
import (
"fmt"
"net/http"
"regexp"
"sort"
"strings"
)
// Lang — язык сообщений панели.
type Lang string
const (
RU Lang = "ru"
EN Lang = "en"
// Default — язык, на котором сообщения написаны в коде.
Default = RU
)
// CookieName — кука, которой панель сообщает выбранный человеком язык.
const CookieName = "pzm_lang"
// Parse разбирает код языка; для незнакомого возвращает язык по умолчанию.
func Parse(value string) (Lang, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "ru":
return RU, true
case "en":
return EN, true
}
return Default, false
}
// FromRequest определяет язык ответа. Выбор человека (кука, которую ставит
// панель) важнее списка языков браузера: на сервер могут заходить с чужой
// машины, а язык панели человек выбирает сам.
func FromRequest(r *http.Request) Lang {
if r == nil {
return Default
}
if cookie, err := r.Cookie(CookieName); err == nil {
if lang, ok := Parse(cookie.Value); ok {
return lang
}
}
return fromAcceptLanguage(r.Header.Get("Accept-Language"))
}
// fromAcceptLanguage берёт первый понятный язык из заголовка. Веса (q=) не
// разбираем: браузеры и так перечисляют языки по убыванию предпочтения.
func fromAcceptLanguage(header string) Lang {
for _, part := range strings.Split(header, ",") {
tag := strings.TrimSpace(part)
if i := strings.IndexByte(tag, ';'); i >= 0 {
tag = tag[:i]
}
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
if lang, ok := Parse(tag); ok {
return lang
}
}
return Default
}
// T переводит сообщение на указанный язык. Неизвестное сообщение возвращается
// без изменений: потерять текст хуже, чем показать его на чужом языке.
func T(lang Lang, message string) string {
if lang == Default || lang == "" || message == "" {
return message
}
return translate(message, 0)
}
// maxDepth ограничивает разбор вложенных ошибок: «прочитать X: создать Y: ...».
const maxDepth = 4
func translate(message string, depth int) string {
if text, ok := messages[message]; ok {
return text
}
if depth >= maxDepth {
return message
}
for _, p := range patterns {
match := p.re.FindStringSubmatch(message)
if match == nil {
continue
}
// Подставленные значения переводим тоже: в них попадает вложенная
// ошибка целиком, и она может быть знакома словарю.
args := make([]any, 0, len(match)-1)
for _, group := range match[1:] {
args = append(args, translate(group, depth+1))
}
return fmt.Sprintf(p.en, args...)
}
return message
}
// pattern — пара форматов «русский → английский». Значения, подставленные в
// сообщение при его сборке, вылавливаются группами регулярного выражения.
type pattern struct {
re *regexp.Regexp
en string
// weight — длина неизменяемой части. Чем она больше, тем конкретнее
// шаблон, и тем раньше его стоит примерить.
weight int
}
var patterns []pattern
func init() {
patterns = make([]pattern, 0, len(formats))
for _, f := range formats {
patterns = append(patterns, compile(f[0], f[1]))
}
sort.SliceStable(patterns, func(i, j int) bool {
return patterns[i].weight > patterns[j].weight
})
}
func compile(ru, en string) pattern {
parts := strings.Split(ru, "%s")
weight := 0
for _, part := range parts {
weight += len(part)
}
var body strings.Builder
body.WriteString(regexp.QuoteMeta(parts[0]))
for i := 1; i < len(parts); i++ {
// Последняя группа жадная: в неё попадает вложенная ошибка целиком,
// вместе со своими двоеточиями.
group := "(.+?)"
if i == len(parts)-1 && parts[i] == "" {
group = "(.+)"
}
body.WriteString(group)
body.WriteString(regexp.QuoteMeta(parts[i]))
}
// (?s) — сообщения бывают многострочными.
return pattern{re: regexp.MustCompile("(?s)^" + body.String() + "$"), en: en, weight: weight}
}