Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package i18n
|
||||
|
||||
// Stable machine codes that must never be translated when used as the JSON
|
||||
// "error" / "code" field value. Clients branch on these exact strings.
|
||||
var stableCodes = map[string]struct{}{
|
||||
"password_not_set": {},
|
||||
"email_mismatch": {},
|
||||
"maintenance": {},
|
||||
"read_only": {},
|
||||
"already_claimed": {},
|
||||
"not_claimable": {},
|
||||
"invalid_credentials": {},
|
||||
"user_already_exists": {},
|
||||
"password_too_short": {},
|
||||
"register_fields_required": {},
|
||||
}
|
||||
|
||||
// IsStableCode reports whether msg is a machine-stable error token.
|
||||
func IsStableCode(msg string) bool {
|
||||
_, ok := stableCodes[msg]
|
||||
return ok
|
||||
}
|
||||
|
||||
// T returns msg translated for locale. Missing entries fall back to English msg.
|
||||
// Stable machine codes are returned unchanged.
|
||||
func T(locale, msg string) string {
|
||||
if msg == "" || IsStableCode(msg) {
|
||||
return msg
|
||||
}
|
||||
lang := Normalize(locale)
|
||||
if lang == Default {
|
||||
return msg
|
||||
}
|
||||
if pack, ok := catalogs[lang]; ok {
|
||||
if translated, ok := pack[msg]; ok && translated != "" {
|
||||
return translated
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// catalogs maps locale → (English source message → translation).
|
||||
// English is the identity key; add entries here when introducing new public copy.
|
||||
var catalogs = map[string]map[string]string{
|
||||
"es": esMessages,
|
||||
"fr": frMessages,
|
||||
"de": deMessages,
|
||||
"it": itMessages,
|
||||
"pt": ptMessages,
|
||||
"nl": nlMessages,
|
||||
"pl": plMessages,
|
||||
"ja": jaMessages,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Default is the fallback UI/API locale when Accept-Language is missing or unsupported.
|
||||
const Default = "en"
|
||||
|
||||
// Supported UI/API locales for public error/validation copy.
|
||||
// Keep aligned with apps/web/src/lib/i18n/locales.ts (UI_LOCALES).
|
||||
var Supported = []string{"en", "es", "fr", "de", "it", "pt", "nl", "pl", "ja"}
|
||||
|
||||
var supportedSet map[string]struct{}
|
||||
|
||||
func init() {
|
||||
supportedSet = make(map[string]struct{}, len(Supported))
|
||||
for _, code := range Supported {
|
||||
supportedSet[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithLocale stores a resolved locale on ctx.
|
||||
func WithLocale(ctx context.Context, locale string) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, Normalize(locale))
|
||||
}
|
||||
|
||||
// FromContext returns the locale stored by middleware, or Default.
|
||||
func FromContext(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return Default
|
||||
}
|
||||
if v, ok := ctx.Value(ctxKey{}).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// Normalize lowercases/trims and maps to a supported primary language tag, or Default.
|
||||
func Normalize(raw string) string {
|
||||
code := strings.ToLower(strings.TrimSpace(raw))
|
||||
if code == "" || code == "*" {
|
||||
return Default
|
||||
}
|
||||
if i := strings.IndexByte(code, '-'); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
if i := strings.IndexByte(code, '_'); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
if _, ok := supportedSet[code]; ok {
|
||||
return code
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// IsSupported reports whether the primary language tag is in Supported.
|
||||
func IsSupported(raw string) bool {
|
||||
code := strings.ToLower(strings.TrimSpace(raw))
|
||||
if code == "" {
|
||||
return false
|
||||
}
|
||||
if i := strings.IndexByte(code, '-'); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
if i := strings.IndexByte(code, '_'); i > 0 {
|
||||
code = code[:i]
|
||||
}
|
||||
_, ok := supportedSet[code]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Resolve picks the best supported locale from an Accept-Language header value.
|
||||
// Quality values are respected; unsupported tags are skipped; empty → Default.
|
||||
func Resolve(acceptLanguage string) string {
|
||||
header := strings.TrimSpace(acceptLanguage)
|
||||
if header == "" {
|
||||
return Default
|
||||
}
|
||||
bestTag := ""
|
||||
bestQ := -1.0
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tag := part
|
||||
q := 1.0
|
||||
if i := strings.IndexByte(part, ';'); i >= 0 {
|
||||
tag = strings.TrimSpace(part[:i])
|
||||
for _, p := range strings.Split(part[i+1:], ";") {
|
||||
p = strings.TrimSpace(p)
|
||||
if len(p) >= 2 && (p[0] == 'q' || p[0] == 'Q') && p[1] == '=' {
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(p[2:]), 64); err == nil {
|
||||
q = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
primary := strings.ToLower(strings.TrimSpace(tag))
|
||||
if primary == "*" {
|
||||
if q > bestQ {
|
||||
bestQ = q
|
||||
bestTag = Default
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !IsSupported(primary) {
|
||||
continue
|
||||
}
|
||||
norm := Normalize(primary)
|
||||
if q > bestQ {
|
||||
bestQ = q
|
||||
bestTag = norm
|
||||
}
|
||||
}
|
||||
if bestTag == "" {
|
||||
return Default
|
||||
}
|
||||
return bestTag
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveAcceptLanguage(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", Default},
|
||||
{"en", "en"},
|
||||
{"nl-NL,nl;q=0.9,en;q=0.8", "nl"},
|
||||
{"fr-CA,en;q=0.5", "fr"},
|
||||
{"xx-YY,en;q=0.1", "en"},
|
||||
{"de;q=0.2,pl;q=0.9", "pl"},
|
||||
{"*;q=0.1", "en"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := Resolve(tc.in); got != tc.want {
|
||||
t.Fatalf("Resolve(%q)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallsBackAndSkipsStableCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := T("nl", "unauthorized"); got != "niet geautoriseerd" {
|
||||
t.Fatalf("nl unauthorized=%q", got)
|
||||
}
|
||||
if got := T("nl", "password_not_set"); got != "password_not_set" {
|
||||
t.Fatalf("stable code translated: %q", got)
|
||||
}
|
||||
if got := T("nl", "some unknown message"); got != "some unknown message" {
|
||||
t.Fatalf("missing key should stay English: %q", got)
|
||||
}
|
||||
if got := T("en", "unauthorized"); got != "unauthorized" {
|
||||
t.Fatalf("en identity=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedMatchesUILocales(t *testing.T) {
|
||||
t.Parallel()
|
||||
want := []string{"en", "es", "fr", "de", "it", "pt", "nl", "pl", "ja"}
|
||||
if len(Supported) != len(want) {
|
||||
t.Fatalf("Supported len=%d want %d", len(Supported), len(want))
|
||||
}
|
||||
for i, code := range want {
|
||||
if Supported[i] != code {
|
||||
t.Fatalf("Supported[%d]=%q want %q", i, Supported[i], code)
|
||||
}
|
||||
if !IsSupported(code) {
|
||||
t.Fatalf("IsSupported(%q)=false", code)
|
||||
}
|
||||
}
|
||||
if IsSupported("xx") {
|
||||
t.Fatal("xx must not be supported")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package i18n
|
||||
|
||||
// Locale message packs (English source string → translation).
|
||||
// Missing entries fall back to the English source via T. Keep keys identical
|
||||
// to the English public Error()/CodedError message text. Stable machine codes
|
||||
// (password_not_set, …) must not appear here — IsStableCode leaves them alone.
|
||||
|
||||
var esMessages = map[string]string{
|
||||
"unauthorized": "no autorizado",
|
||||
"Unauthorized": "No autorizado",
|
||||
"forbidden": "prohibido",
|
||||
"not found": "no encontrado",
|
||||
"invalid api key": "clave API no válida",
|
||||
"invalid credentials": "credenciales no válidas",
|
||||
"invalid json": "JSON no válido",
|
||||
"invalid email": "correo no válido",
|
||||
"user not found": "usuario no encontrado",
|
||||
"company not found": "empresa no encontrada",
|
||||
"company required": "se requiere empresa",
|
||||
"method not allowed": "método no permitido",
|
||||
"rate limit exceeded": "límite de velocidad superado",
|
||||
"csrf token mismatch": "token CSRF no coincide",
|
||||
"login failed": "error al iniciar sesión",
|
||||
"logout failed": "error al cerrar sesión",
|
||||
"Authentication failed": "Error de autenticación",
|
||||
"admin required": "se requiere administrador",
|
||||
"platform admin required": "se requiere administrador de plataforma",
|
||||
"database unavailable": "base de datos no disponible",
|
||||
"auth unavailable": "autenticación no disponible",
|
||||
"body too large": "cuerpo demasiado grande",
|
||||
"user already exists": "el usuario ya existe",
|
||||
"password must be at least 8 characters": "la contraseña debe tener al menos 8 caracteres",
|
||||
"invite invalid or expired": "invitación no válida o caducada",
|
||||
"unsupported language": "idioma no admitido",
|
||||
}
|
||||
|
||||
var frMessages = map[string]string{
|
||||
"unauthorized": "non autorisé",
|
||||
"Unauthorized": "Non autorisé",
|
||||
"forbidden": "interdit",
|
||||
"not found": "introuvable",
|
||||
"invalid api key": "clé API invalide",
|
||||
"invalid credentials": "identifiants invalides",
|
||||
"invalid json": "JSON invalide",
|
||||
"invalid email": "e-mail invalide",
|
||||
"user not found": "utilisateur introuvable",
|
||||
"company not found": "entreprise introuvable",
|
||||
"company required": "entreprise requise",
|
||||
"method not allowed": "méthode non autorisée",
|
||||
"rate limit exceeded": "limite de débit dépassée",
|
||||
"csrf token mismatch": "jeton CSRF non concordant",
|
||||
"login failed": "échec de la connexion",
|
||||
"logout failed": "échec de la déconnexion",
|
||||
"Authentication failed": "Échec de l'authentification",
|
||||
"admin required": "administrateur requis",
|
||||
"platform admin required": "administrateur de plateforme requis",
|
||||
"database unavailable": "base de données indisponible",
|
||||
"auth unavailable": "authentification indisponible",
|
||||
"body too large": "corps trop volumineux",
|
||||
"user already exists": "l'utilisateur existe déjà",
|
||||
"password must be at least 8 characters": "le mot de passe doit contenir au moins 8 caractères",
|
||||
"invite invalid or expired": "invitation invalide ou expirée",
|
||||
"unsupported language": "langue non prise en charge",
|
||||
}
|
||||
|
||||
var deMessages = map[string]string{
|
||||
"unauthorized": "nicht autorisiert",
|
||||
"Unauthorized": "Nicht autorisiert",
|
||||
"forbidden": "verboten",
|
||||
"not found": "nicht gefunden",
|
||||
"invalid api key": "ungültiger API-Schlüssel",
|
||||
"invalid credentials": "ungültige Anmeldedaten",
|
||||
"invalid json": "ungültiges JSON",
|
||||
"invalid email": "ungültige E-Mail",
|
||||
"user not found": "Benutzer nicht gefunden",
|
||||
"company not found": "Unternehmen nicht gefunden",
|
||||
"company required": "Unternehmen erforderlich",
|
||||
"method not allowed": "Methode nicht erlaubt",
|
||||
"rate limit exceeded": "Ratenlimit überschritten",
|
||||
"csrf token mismatch": "CSRF-Token stimmt nicht überein",
|
||||
"login failed": "Anmeldung fehlgeschlagen",
|
||||
"logout failed": "Abmeldung fehlgeschlagen",
|
||||
"Authentication failed": "Authentifizierung fehlgeschlagen",
|
||||
"admin required": "Admin erforderlich",
|
||||
"platform admin required": "Plattform-Admin erforderlich",
|
||||
"database unavailable": "Datenbank nicht verfügbar",
|
||||
"auth unavailable": "Authentifizierung nicht verfügbar",
|
||||
"body too large": "Anfragetext zu groß",
|
||||
"user already exists": "Benutzer existiert bereits",
|
||||
"password must be at least 8 characters": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"invite invalid or expired": "Einladung ungültig oder abgelaufen",
|
||||
"unsupported language": "nicht unterstützte Sprache",
|
||||
}
|
||||
|
||||
var itMessages = map[string]string{
|
||||
"unauthorized": "non autorizzato",
|
||||
"Unauthorized": "Non autorizzato",
|
||||
"forbidden": "vietato",
|
||||
"not found": "non trovato",
|
||||
"invalid api key": "chiave API non valida",
|
||||
"invalid credentials": "credenziali non valide",
|
||||
"invalid json": "JSON non valido",
|
||||
"invalid email": "email non valida",
|
||||
"user not found": "utente non trovato",
|
||||
"company not found": "azienda non trovata",
|
||||
"company required": "azienda richiesta",
|
||||
"method not allowed": "metodo non consentito",
|
||||
"rate limit exceeded": "limite di frequenza superato",
|
||||
"csrf token mismatch": "token CSRF non corrispondente",
|
||||
"login failed": "accesso non riuscito",
|
||||
"logout failed": "disconnessione non riuscita",
|
||||
"Authentication failed": "Autenticazione non riuscita",
|
||||
"admin required": "amministratore richiesto",
|
||||
"platform admin required": "amministratore della piattaforma richiesto",
|
||||
"database unavailable": "database non disponibile",
|
||||
"auth unavailable": "autenticazione non disponibile",
|
||||
"body too large": "corpo troppo grande",
|
||||
"user already exists": "l'utente esiste già",
|
||||
"password must be at least 8 characters": "la password deve avere almeno 8 caratteri",
|
||||
"invite invalid or expired": "invito non valido o scaduto",
|
||||
"unsupported language": "lingua non supportata",
|
||||
}
|
||||
|
||||
var ptMessages = map[string]string{
|
||||
"unauthorized": "não autorizado",
|
||||
"Unauthorized": "Não autorizado",
|
||||
"forbidden": "proibido",
|
||||
"not found": "não encontrado",
|
||||
"invalid api key": "chave API inválida",
|
||||
"invalid credentials": "credenciais inválidas",
|
||||
"invalid json": "JSON inválido",
|
||||
"invalid email": "e-mail inválido",
|
||||
"user not found": "utilizador não encontrado",
|
||||
"company not found": "empresa não encontrada",
|
||||
"company required": "empresa obrigatória",
|
||||
"method not allowed": "método não permitido",
|
||||
"rate limit exceeded": "limite de taxa excedido",
|
||||
"csrf token mismatch": "token CSRF não coincide",
|
||||
"login failed": "falha no início de sessão",
|
||||
"logout failed": "falha ao terminar sessão",
|
||||
"Authentication failed": "Falha de autenticação",
|
||||
"admin required": "administrador obrigatório",
|
||||
"platform admin required": "administrador da plataforma obrigatório",
|
||||
"database unavailable": "base de dados indisponível",
|
||||
"auth unavailable": "autenticação indisponível",
|
||||
"body too large": "corpo demasiado grande",
|
||||
"user already exists": "o utilizador já existe",
|
||||
"password must be at least 8 characters": "a palavra-passe deve ter pelo menos 8 caracteres",
|
||||
"invite invalid or expired": "convite inválido ou expirado",
|
||||
"unsupported language": "idioma não suportado",
|
||||
}
|
||||
|
||||
var nlMessages = map[string]string{
|
||||
"unauthorized": "niet geautoriseerd",
|
||||
"Unauthorized": "Niet geautoriseerd",
|
||||
"forbidden": "verboden",
|
||||
"not found": "niet gevonden",
|
||||
"invalid api key": "ongeldige API-sleutel",
|
||||
"invalid credentials": "ongeldige inloggegevens",
|
||||
"invalid json": "ongeldige JSON",
|
||||
"invalid email": "ongeldig e-mailadres",
|
||||
"user not found": "gebruiker niet gevonden",
|
||||
"company not found": "bedrijf niet gevonden",
|
||||
"company required": "bedrijf verplicht",
|
||||
"method not allowed": "methode niet toegestaan",
|
||||
"rate limit exceeded": "limiet overschreden",
|
||||
"csrf token mismatch": "CSRF-token komt niet overeen",
|
||||
"login failed": "inloggen mislukt",
|
||||
"logout failed": "uitloggen mislukt",
|
||||
"Authentication failed": "Authenticatie mislukt",
|
||||
"admin required": "beheerder vereist",
|
||||
"platform admin required": "platformbeheerder vereist",
|
||||
"database unavailable": "database niet beschikbaar",
|
||||
"auth unavailable": "authenticatie niet beschikbaar",
|
||||
"body too large": "body te groot",
|
||||
"user already exists": "gebruiker bestaat al",
|
||||
"password must be at least 8 characters": "wachtwoord moet minstens 8 tekens hebben",
|
||||
"invite invalid or expired": "uitnodiging ongeldig of verlopen",
|
||||
"unsupported language": "niet-ondersteunde taal",
|
||||
}
|
||||
|
||||
var plMessages = map[string]string{
|
||||
"unauthorized": "nieautoryzowany",
|
||||
"Unauthorized": "Nieautoryzowany",
|
||||
"forbidden": "zabronione",
|
||||
"not found": "nie znaleziono",
|
||||
"invalid api key": "nieprawidłowy klucz API",
|
||||
"invalid credentials": "nieprawidłowe dane logowania",
|
||||
"invalid json": "nieprawidłowy JSON",
|
||||
"invalid email": "nieprawidłowy e-mail",
|
||||
"user not found": "nie znaleziono użytkownika",
|
||||
"company not found": "nie znaleziono firmy",
|
||||
"company required": "wymagana firma",
|
||||
"method not allowed": "metoda niedozwolona",
|
||||
"rate limit exceeded": "przekroczono limit żądań",
|
||||
"csrf token mismatch": "token CSRF nie pasuje",
|
||||
"login failed": "logowanie nie powiodło się",
|
||||
"logout failed": "wylogowanie nie powiodło się",
|
||||
"Authentication failed": "Uwierzytelnianie nie powiodło się",
|
||||
"admin required": "wymagany administrator",
|
||||
"platform admin required": "wymagany administrator platformy",
|
||||
"database unavailable": "baza danych niedostępna",
|
||||
"auth unavailable": "uwierzytelnianie niedostępne",
|
||||
"body too large": "ciało żądania zbyt duże",
|
||||
"user already exists": "użytkownik już istnieje",
|
||||
"password must be at least 8 characters": "hasło musi mieć co najmniej 8 znaków",
|
||||
"invite invalid or expired": "zaproszenie nieprawidłowe lub wygasłe",
|
||||
"unsupported language": "nieobsługiwany język",
|
||||
}
|
||||
|
||||
var jaMessages = map[string]string{
|
||||
"unauthorized": "認証されていません",
|
||||
"Unauthorized": "認証されていません",
|
||||
"forbidden": "禁止されています",
|
||||
"not found": "見つかりません",
|
||||
"invalid api key": "無効なAPIキー",
|
||||
"invalid credentials": "無効な認証情報",
|
||||
"invalid json": "無効なJSON",
|
||||
"invalid email": "無効なメールアドレス",
|
||||
"user not found": "ユーザーが見つかりません",
|
||||
"company not found": "会社が見つかりません",
|
||||
"company required": "会社が必要です",
|
||||
"method not allowed": "許可されていないメソッド",
|
||||
"rate limit exceeded": "レート制限を超えました",
|
||||
"csrf token mismatch": "CSRFトークンが一致しません",
|
||||
"login failed": "ログインに失敗しました",
|
||||
"logout failed": "ログアウトに失敗しました",
|
||||
"Authentication failed": "認証に失敗しました",
|
||||
"admin required": "管理者が必要です",
|
||||
"platform admin required": "プラットフォーム管理者が必要です",
|
||||
"database unavailable": "データベースを利用できません",
|
||||
"auth unavailable": "認証を利用できません",
|
||||
"body too large": "リクエスト本文が大きすぎます",
|
||||
"user already exists": "ユーザーは既に存在します",
|
||||
"password must be at least 8 characters": "パスワードは8文字以上である必要があります",
|
||||
"invite invalid or expired": "招待が無効または期限切れです",
|
||||
"unsupported language": "サポートされていない言語",
|
||||
}
|
||||
Reference in New Issue
Block a user