This commit is contained in:
26
pkg/i18n/embed.go
Normal file
26
pkg/i18n/embed.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed locales/*.json
|
||||
var localeFiles embed.FS
|
||||
|
||||
// catalogFS is the raw JSON for each locale in Supported, read once at
|
||||
// package init time (see i18n.go's init). Keeping the embed.FS read
|
||||
// here, separate from the JSON-decoding in init, means adding a
|
||||
// locale is purely data: list it in Supported above, drop its file
|
||||
// under locales/, and this loop picks it up — no new Go code.
|
||||
var catalogFS = func() map[Locale][]byte {
|
||||
out := make(map[Locale][]byte, len(Supported))
|
||||
for _, info := range Supported {
|
||||
data, err := localeFiles.ReadFile(fmt.Sprintf("locales/%s.json", info.Code))
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("i18n: locale %q listed in Supported but locales/%s.json is missing: %v", info.Code, info.Code, err))
|
||||
}
|
||||
out[info.Code] = data
|
||||
}
|
||||
return out
|
||||
}()
|
||||
166
pkg/i18n/i18n.go
Normal file
166
pkg/i18n/i18n.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Package i18n is enkly's shared translation catalog: the single
|
||||
// source of truth, for every service, of which UI languages exist and
|
||||
// what their strings are. It is deliberately tiny and dependency-free
|
||||
// (encoding/json + embed, nothing else) so both the gateway (which
|
||||
// renders the HTML chrome a visitor reads) and the agent service
|
||||
// (which needs the same locale codes and display names to tell the
|
||||
// model which language the visitor has chosen — see
|
||||
// agentrun.WithPreferredLanguage) can import it without pulling either
|
||||
// service into the other's build.
|
||||
//
|
||||
// This is UI-chrome localization — buttons, labels, system-generated
|
||||
// notices ("you've reached today's message limit") — not a substitute
|
||||
// for the agent's own language behavior: the model already detects
|
||||
// and mirrors whatever language a user types in conversation (see
|
||||
// agentrun.SystemPrompt's "## Language" section). The two compose: a
|
||||
// visitor who has never touched the language switcher still gets
|
||||
// replies in their own language because the model notices; a visitor
|
||||
// who *has* set a UI language gets the chrome in that language too,
|
||||
// and the agent treats it as their standing preference (see
|
||||
// agentrun.WithPreferredLanguage's doc comment for exactly how the
|
||||
// two signals are reconciled when they disagree).
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Locale is a BCP-47-ish language tag — in practice just the short
|
||||
// codes listed in Supported below ("en", "fr", …). It's a defined
|
||||
// type rather than a bare string so a caller can't accidentally pass
|
||||
// a translation key where a locale was expected, or vice versa.
|
||||
type Locale string
|
||||
|
||||
// Default is used whenever no locale can be determined at all (no
|
||||
// cookie, no Accept-Language header, no account preference) and as
|
||||
// the fallback catalog for any key missing from a more specific
|
||||
// locale's translation file.
|
||||
const Default Locale = "en"
|
||||
|
||||
// Info is one supported locale's code and its own display name (in
|
||||
// that language, not in the viewer's — "Français", not "French" —
|
||||
// which is how every language picker from a browser's own settings to
|
||||
// Claude Code's `/language` command does it).
|
||||
type Info struct {
|
||||
Code Locale
|
||||
Name string
|
||||
}
|
||||
|
||||
// Supported lists every locale enkly ships a catalog for, in the
|
||||
// order a language switcher should present them. Adding a language is
|
||||
// a two-line change: a new entry here, naming the file added under
|
||||
// locales/, and the entry in catalogs below the embed directive.
|
||||
//
|
||||
// enkly's first deployments are French West African (see the FCFA
|
||||
// pricing in the billing service), so French ships complete; the rest
|
||||
// cover enough of the chrome (nav, composer, auth, errors) to be
|
||||
// usable, falling back to English for anything not yet translated —
|
||||
// see T's doc comment. Extending any of them to full coverage is a
|
||||
// matter of filling in locales/<code>.json, not touching any Go code.
|
||||
var Supported = []Info{
|
||||
{Code: "en", Name: "English"},
|
||||
{Code: "fr", Name: "Français"},
|
||||
{Code: "es", Name: "Español"},
|
||||
{Code: "de", Name: "Deutsch"},
|
||||
{Code: "pt", Name: "Português"},
|
||||
}
|
||||
|
||||
// IsSupported reports whether code names a locale enkly ships a
|
||||
// catalog for.
|
||||
func IsSupported(code string) bool {
|
||||
for _, info := range Supported {
|
||||
if string(info.Code) == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseLocale returns code as a Locale if it's one enkly supports,
|
||||
// else Default. Use this anywhere an untrusted string (a cookie
|
||||
// value, a query parameter, one tag out of an Accept-Language header)
|
||||
// needs to become a Locale — it never returns an unsupported value.
|
||||
func ParseLocale(code string) Locale {
|
||||
if IsSupported(code) {
|
||||
return Locale(code)
|
||||
}
|
||||
return Default
|
||||
}
|
||||
|
||||
// NameOf returns a locale's own display name ("Français" for "fr"),
|
||||
// or its bare code if it isn't one of Supported (shouldn't happen for
|
||||
// a Locale that came from ParseLocale, but T and callers that build a
|
||||
// prompt string from a Locale value want something printable either
|
||||
// way).
|
||||
func NameOf(l Locale) string {
|
||||
for _, info := range Supported {
|
||||
if info.Code == l {
|
||||
return info.Name
|
||||
}
|
||||
}
|
||||
return string(l)
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
missLog = map[string]bool{} // "locale/key" already warned about, so a hot template doesn't spam the log
|
||||
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
catalogs map[Locale]map[string]string
|
||||
)
|
||||
|
||||
func init() {
|
||||
catalogs = make(map[Locale]map[string]string, len(catalogFS))
|
||||
for locale, raw := range catalogFS {
|
||||
var strings map[string]string
|
||||
if err := json.Unmarshal(raw, &strings); err != nil {
|
||||
// A malformed embedded catalog is a build-time mistake, not
|
||||
// a runtime condition any caller can recover from — fail
|
||||
// loudly at process start rather than silently serving
|
||||
// blank strings in production.
|
||||
panic(fmt.Sprintf("i18n: locale %q: invalid catalog JSON: %v", locale, err))
|
||||
}
|
||||
catalogs[locale] = strings
|
||||
}
|
||||
}
|
||||
|
||||
// T looks up key in locale's catalog and, if args is non-empty, runs
|
||||
// the result through fmt.Sprintf (so a catalog entry can contain %s /
|
||||
// %d verbs the same way Go source would). A key missing from locale
|
||||
// falls back to the Default catalog, and a key missing from Default
|
||||
// too falls back to the key itself — so a template that references a
|
||||
// not-yet-translated string degrades to showing its own key (an
|
||||
// unmistakable "translate me" placeholder during development) instead
|
||||
// of a blank space or a panic in production. Each distinct miss is
|
||||
// logged once per process, not once per request.
|
||||
func T(locale Locale, key string, args ...any) string {
|
||||
mu.RLock()
|
||||
s, ok := catalogs[locale][key]
|
||||
if !ok {
|
||||
s, ok = catalogs[Default][key]
|
||||
}
|
||||
mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
warnMissing(locale, key)
|
||||
s = key
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprintf(s, args...)
|
||||
}
|
||||
|
||||
func warnMissing(locale Locale, key string) {
|
||||
id := string(locale) + "/" + key
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if missLog[id] {
|
||||
return
|
||||
}
|
||||
missLog[id] = true
|
||||
logger.Warn("i18n: missing translation key", "locale", locale, "key", key)
|
||||
}
|
||||
54
pkg/i18n/i18n_test.go
Normal file
54
pkg/i18n/i18n_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSupportedLocalesLoad(t *testing.T) {
|
||||
for _, info := range Supported {
|
||||
cat, ok := catalogs[info.Code]
|
||||
if !ok || len(cat) == 0 {
|
||||
t.Fatalf("locale %q: catalog missing or empty", info.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnglishHasEveryKeyUsedElsewhere(t *testing.T) {
|
||||
// English is the fallback catalog (see T's doc comment), so every
|
||||
// key any other locale defines must also exist in English — a
|
||||
// locale-only key would be silently unreachable from the fallback
|
||||
// path and is almost certainly a typo.
|
||||
en := catalogs[Default]
|
||||
for _, info := range Supported {
|
||||
if info.Code == Default {
|
||||
continue
|
||||
}
|
||||
for key := range catalogs[info.Code] {
|
||||
if _, ok := en[key]; !ok {
|
||||
t.Errorf("locale %q defines key %q that is missing from %q (the fallback catalog)", info.Code, key, Default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTFallsBackToEnglishThenToKey(t *testing.T) {
|
||||
if got := T("fr", "nav.new_chat"); got != "Nouvelle discussion" {
|
||||
t.Errorf("fr nav.new_chat = %q", got)
|
||||
}
|
||||
// "nav.delete" only exists in a handful of locales' full set; pick
|
||||
// a locale/key combination guaranteed to be English-only to prove
|
||||
// the fallback path, without hard-coding which keys those are.
|
||||
if got := T("es", "billing.quota_exceeded", "Pro", 200); got == "billing.quota_exceeded" {
|
||||
t.Errorf("expected fallback to the English catalog, got the raw key back")
|
||||
}
|
||||
if got := T(Default, "this.key.does.not.exist"); got != "this.key.does.not.exist" {
|
||||
t.Errorf("unknown key should fall back to itself, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLocale(t *testing.T) {
|
||||
if got := ParseLocale("fr"); got != "fr" {
|
||||
t.Errorf("ParseLocale(fr) = %q", got)
|
||||
}
|
||||
if got := ParseLocale("xx-not-a-locale"); got != Default {
|
||||
t.Errorf("ParseLocale(unsupported) = %q, want Default", got)
|
||||
}
|
||||
}
|
||||
74
pkg/i18n/locales/de.json
Normal file
74
pkg/i18n/locales/de.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Neuer Chat",
|
||||
"nav.conversations": "Unterhaltungen",
|
||||
"nav.no_conversations": "Noch keine Unterhaltungen.",
|
||||
"nav.plan_suffix": "Tarif",
|
||||
"nav.manage": "Verwalten",
|
||||
"nav.log_out": "Abmelden",
|
||||
"nav.theme_light": "Hell",
|
||||
"nav.theme_dark": "Dunkel",
|
||||
"nav.theme_auto": "Automatisch",
|
||||
"nav.language": "Sprache",
|
||||
"nav.search_placeholder": "Chats, Dateien, Aktionen durchsuchen…",
|
||||
"nav.agent_ready": "Agent bereit",
|
||||
"nav.rename_save": "Namen speichern",
|
||||
"nav.delete": "Löschen",
|
||||
"nav.close_sidebar": "Seitenleiste schließen",
|
||||
"nav.open_sidebar": "Seitenleiste öffnen",
|
||||
|
||||
"hero.title": "Was bauen wir heute?",
|
||||
"hero.subtitle": "enkly arbeitet direkt in deinem Arbeitsbereich: liest, durchsucht, bearbeitet und führt Code aus und zeigt jeden Schritt.",
|
||||
"hero.start_with": "Beginne mit",
|
||||
"hero.tools_heading": "Was enkly nutzen kann",
|
||||
"hero.explore_heading": "Mehr entdecken",
|
||||
|
||||
"starter.tour_title": "Arbeitsbereich erkunden",
|
||||
"starter.tour_desc": "Ordner, Dienste und Einstiegspunkte kartieren.",
|
||||
"starter.trace_title": "Eine Funktion verfolgen",
|
||||
"starter.trace_desc": "Eine Anfrage durch den Code verfolgen.",
|
||||
"starter.fix_title": "Einen Fehler beheben",
|
||||
"starter.fix_desc": "Beschreibe ihn, enkly findet und behebt ihn.",
|
||||
"starter.tests_title": "Tests ausführen",
|
||||
"starter.tests_desc": "Testsuite ausführen und Fehlschläge erklären.",
|
||||
|
||||
"tool.read": "Dateien lesen",
|
||||
"tool.write": "Schreiben und bearbeiten",
|
||||
"tool.search": "Codebasis durchsuchen",
|
||||
"tool.run": "Erlaubte Befehle ausführen",
|
||||
"tool.subagents": "Subagenten",
|
||||
|
||||
"explore.tests": "Tests schreiben für…",
|
||||
"explore.review": "Code überprüfen",
|
||||
"explore.explain": "Eine Datei erklären",
|
||||
"explore.document": "Ein Dokument entwerfen",
|
||||
"explore.refactor": "Refaktorisieren",
|
||||
|
||||
"composer.placeholder": "Bitte enkly, etwas zu bauen, zu reparieren oder zu erklären…",
|
||||
"composer.send": "Senden",
|
||||
"composer.attach": "Dateien oder Fotos hinzufügen",
|
||||
"composer.hint_send": "senden",
|
||||
"composer.hint_newline": "neue Zeile",
|
||||
"composer.hint_drop": "Dateien ablegen, Screenshots einfügen oder ein .zip-Projekt hinzufügen",
|
||||
|
||||
"auth.login_title": "Anmelden",
|
||||
"auth.login_button": "Anmelden",
|
||||
"auth.email_label": "E-Mail",
|
||||
"auth.password_label": "Passwort",
|
||||
"auth.confirm_password_label": "Passwort bestätigen",
|
||||
"auth.no_account": "Noch kein Konto?",
|
||||
"auth.signup_link": "Registrieren",
|
||||
"auth.signup_title": "Konto erstellen",
|
||||
"auth.signup_button": "Registrieren",
|
||||
"auth.have_account": "Bereits ein Konto?",
|
||||
"auth.login_link": "Anmelden",
|
||||
"auth.forgot_link": "Passwort vergessen?",
|
||||
"auth.verify_title": "E-Mail bestätigen",
|
||||
"auth.verify_code_label": "6-stelliger Code",
|
||||
"auth.verify_button": "Bestätigen",
|
||||
"auth.resend_link": "Code erneut senden",
|
||||
"auth.forgot_title": "Passwort zurücksetzen",
|
||||
"auth.forgot_button": "Link zum Zurücksetzen senden",
|
||||
"auth.back_to_login": "Zurück zur Anmeldung",
|
||||
"auth.reset_title": "Neues Passwort wählen",
|
||||
"auth.reset_button": "Passwort zurücksetzen"
|
||||
}
|
||||
104
pkg/i18n/locales/en.json
Normal file
104
pkg/i18n/locales/en.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "New chat",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "No conversations yet.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Manage",
|
||||
"nav.log_out": "Log out",
|
||||
"nav.theme_light": "Light",
|
||||
"nav.theme_dark": "Dark",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Language",
|
||||
"nav.search_placeholder": "Search chats, files, actions…",
|
||||
"nav.agent_ready": "Agent ready",
|
||||
"nav.rename_save": "Save name",
|
||||
"nav.delete": "Delete",
|
||||
"nav.close_sidebar": "Close sidebar",
|
||||
"nav.open_sidebar": "Open sidebar",
|
||||
|
||||
"hero.title": "What should we build today?",
|
||||
"hero.subtitle": "enkly works inside your workspace: it reads, searches, edits and runs code, then shows every step.",
|
||||
"hero.start_with": "Start with",
|
||||
"hero.tools_heading": "What enkly can use",
|
||||
"hero.explore_heading": "More to explore",
|
||||
|
||||
"starter.tour_title": "Tour this workspace",
|
||||
"starter.tour_desc": "Map the folders, services and entry points.",
|
||||
"starter.trace_title": "Trace a feature",
|
||||
"starter.trace_desc": "Follow a request through the codebase.",
|
||||
"starter.fix_title": "Fix a bug",
|
||||
"starter.fix_desc": "Describe it, enkly finds and patches it.",
|
||||
"starter.tests_title": "Run the tests",
|
||||
"starter.tests_desc": "Execute the suite and explain what broke.",
|
||||
|
||||
"tool.read": "Read files",
|
||||
"tool.write": "Write and edit",
|
||||
"tool.search": "Search the codebase",
|
||||
"tool.run": "Run allowlisted commands",
|
||||
"tool.subagents": "Sub-agents",
|
||||
|
||||
"explore.tests": "Write tests for…",
|
||||
"explore.review": "Review code",
|
||||
"explore.explain": "Explain a file",
|
||||
"explore.document": "Draft a document",
|
||||
"explore.refactor": "Refactor",
|
||||
|
||||
"composer.placeholder": "Ask enkly to build, fix, or explain anything…",
|
||||
"composer.send": "Send",
|
||||
"composer.attach": "Add files or photos",
|
||||
"composer.hint_send": "send",
|
||||
"composer.hint_newline": "new line",
|
||||
"composer.hint_drop": "Drop files, paste screenshots or add a .zip project",
|
||||
|
||||
"auth.login_title": "Log in",
|
||||
"auth.login_button": "Log in",
|
||||
"auth.email_label": "Email",
|
||||
"auth.password_label": "Password",
|
||||
"auth.confirm_password_label": "Confirm password",
|
||||
"auth.no_account": "Don't have an account?",
|
||||
"auth.signup_link": "Sign up",
|
||||
"auth.signup_title": "Create your account",
|
||||
"auth.signup_button": "Sign up",
|
||||
"auth.have_account": "Already have an account?",
|
||||
"auth.login_link": "Log in",
|
||||
"auth.forgot_link": "Forgot your password?",
|
||||
"auth.verify_title": "Verify your email",
|
||||
"auth.verify_code_label": "6-digit code",
|
||||
"auth.verify_button": "Verify",
|
||||
"auth.resend_link": "Resend code",
|
||||
"auth.forgot_title": "Reset your password",
|
||||
"auth.forgot_button": "Send reset link",
|
||||
"auth.back_to_login": "Back to log in",
|
||||
"auth.reset_title": "Choose a new password",
|
||||
"auth.reset_button": "Reset password",
|
||||
|
||||
"errors.generic": "Something went wrong. Please try again.",
|
||||
"errors.invalid_email": "Enter a valid email address.",
|
||||
"errors.password_min": "Password must be at least %d characters.",
|
||||
"errors.password_max": "Password must be at most %d characters.",
|
||||
"errors.password_mismatch": "Passwords don't match.",
|
||||
"errors.incorrect_credentials": "Incorrect email or password.",
|
||||
"errors.invalid_code": "Enter the 6-digit code.",
|
||||
"errors.code_incorrect": "That code is incorrect or has expired.",
|
||||
"errors.reset_link_invalid": "That reset link is invalid or has expired. Request a new one.",
|
||||
"errors.csrf": "Invalid or missing security token — reload the page and try again.",
|
||||
|
||||
"info.check_inbox": "Check your inbox for a 6-digit verification code.",
|
||||
"info.mail_not_configured": "Email isn't configured on this server — check the server log for your verification code.",
|
||||
"info.resend_sent": "If that account needs verifying, a new code is on its way.",
|
||||
"info.email_verified": "Your email is verified — log in to continue.",
|
||||
"info.password_reset_done": "Your password has been reset — log in with your new password.",
|
||||
"info.forgot_sent": "If that address has an account, a reset link is on its way.",
|
||||
|
||||
"chat.type_message_first": "Type a message first.",
|
||||
"chat.message_too_long": "That message is too long (limit %d characters).",
|
||||
"chat.rate_limited": "Too many requests — slow down a little.",
|
||||
"chat.conversation_gone": "That conversation no longer exists. Start a new chat.",
|
||||
"chat.conversation_busy": "enkly is still replying in this conversation — wait for it to finish, or press Stop.",
|
||||
"chat.too_many_streams": "You have several replies in progress already. Wait for one to finish or press Stop.",
|
||||
"chat.save_failed": "Couldn't save your message. Please try again.",
|
||||
"chat.load_failed": "Couldn't load the conversation. Please try again.",
|
||||
"chat.send_failed": "Something went wrong. Please try again.",
|
||||
|
||||
"billing.quota_exceeded": "You've reached the %s plan's limit of %d messages today. Upgrade on the Subscription page for more."
|
||||
}
|
||||
74
pkg/i18n/locales/es.json
Normal file
74
pkg/i18n/locales/es.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nueva conversación",
|
||||
"nav.conversations": "Conversaciones",
|
||||
"nav.no_conversations": "Aún no hay conversaciones.",
|
||||
"nav.plan_suffix": "plan",
|
||||
"nav.manage": "Gestionar",
|
||||
"nav.log_out": "Cerrar sesión",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Oscuro",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Buscar chats, archivos, acciones…",
|
||||
"nav.agent_ready": "Agente listo",
|
||||
"nav.rename_save": "Guardar nombre",
|
||||
"nav.delete": "Eliminar",
|
||||
"nav.close_sidebar": "Cerrar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "¿Qué construimos hoy?",
|
||||
"hero.subtitle": "enkly trabaja dentro de tu espacio de trabajo: lee, busca, edita y ejecuta código, y muestra cada paso.",
|
||||
"hero.start_with": "Empieza con",
|
||||
"hero.tools_heading": "Lo que enkly puede usar",
|
||||
"hero.explore_heading": "Más para explorar",
|
||||
|
||||
"starter.tour_title": "Recorrer este espacio de trabajo",
|
||||
"starter.tour_desc": "Mapea las carpetas, servicios y puntos de entrada.",
|
||||
"starter.trace_title": "Rastrear una funcionalidad",
|
||||
"starter.trace_desc": "Sigue una solicitud a través del código.",
|
||||
"starter.fix_title": "Corregir un error",
|
||||
"starter.fix_desc": "Descríbelo y enkly lo encuentra y lo corrige.",
|
||||
"starter.tests_title": "Ejecutar las pruebas",
|
||||
"starter.tests_desc": "Ejecuta la suite y explica qué falló.",
|
||||
|
||||
"tool.read": "Leer archivos",
|
||||
"tool.write": "Escribir y editar",
|
||||
"tool.search": "Buscar en el código",
|
||||
"tool.run": "Ejecutar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escribir pruebas para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar un archivo",
|
||||
"explore.document": "Redactar un documento",
|
||||
"explore.refactor": "Refactorizar",
|
||||
|
||||
"composer.placeholder": "Pide a enkly que construya, corrija o explique cualquier cosa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Añadir archivos o fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nueva línea",
|
||||
"composer.hint_drop": "Suelta archivos, pega capturas de pantalla o añade un proyecto .zip",
|
||||
|
||||
"auth.login_title": "Iniciar sesión",
|
||||
"auth.login_button": "Iniciar sesión",
|
||||
"auth.email_label": "Correo electrónico",
|
||||
"auth.password_label": "Contraseña",
|
||||
"auth.confirm_password_label": "Confirmar contraseña",
|
||||
"auth.no_account": "¿No tienes una cuenta?",
|
||||
"auth.signup_link": "Regístrate",
|
||||
"auth.signup_title": "Crea tu cuenta",
|
||||
"auth.signup_button": "Registrarse",
|
||||
"auth.have_account": "¿Ya tienes una cuenta?",
|
||||
"auth.login_link": "Iniciar sesión",
|
||||
"auth.forgot_link": "¿Olvidaste tu contraseña?",
|
||||
"auth.verify_title": "Verifica tu correo",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Restablece tu contraseña",
|
||||
"auth.forgot_button": "Enviar enlace de restablecimiento",
|
||||
"auth.back_to_login": "Volver a iniciar sesión",
|
||||
"auth.reset_title": "Elige una nueva contraseña",
|
||||
"auth.reset_button": "Restablecer contraseña"
|
||||
}
|
||||
104
pkg/i18n/locales/fr.json
Normal file
104
pkg/i18n/locales/fr.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"nav.new_chat": "Nouvelle discussion",
|
||||
"nav.conversations": "Conversations",
|
||||
"nav.no_conversations": "Aucune conversation pour l'instant.",
|
||||
"nav.plan_suffix": "forfait",
|
||||
"nav.manage": "Gérer",
|
||||
"nav.log_out": "Déconnexion",
|
||||
"nav.theme_light": "Clair",
|
||||
"nav.theme_dark": "Sombre",
|
||||
"nav.theme_auto": "Auto",
|
||||
"nav.language": "Langue",
|
||||
"nav.search_placeholder": "Rechercher des discussions, fichiers, actions…",
|
||||
"nav.agent_ready": "Agent prêt",
|
||||
"nav.rename_save": "Enregistrer le nom",
|
||||
"nav.delete": "Supprimer",
|
||||
"nav.close_sidebar": "Fermer la barre latérale",
|
||||
"nav.open_sidebar": "Ouvrir la barre latérale",
|
||||
|
||||
"hero.title": "Que devons-nous construire aujourd'hui ?",
|
||||
"hero.subtitle": "enkly travaille directement dans votre espace de travail : il lit, recherche, modifie et exécute du code, puis affiche chaque étape.",
|
||||
"hero.start_with": "Commencer par",
|
||||
"hero.tools_heading": "Ce qu'enkly peut utiliser",
|
||||
"hero.explore_heading": "À explorer",
|
||||
|
||||
"starter.tour_title": "Visiter cet espace de travail",
|
||||
"starter.tour_desc": "Cartographier les dossiers, services et points d'entrée.",
|
||||
"starter.trace_title": "Suivre une fonctionnalité",
|
||||
"starter.trace_desc": "Suivre une requête à travers le code.",
|
||||
"starter.fix_title": "Corriger un bug",
|
||||
"starter.fix_desc": "Décrivez-le, enkly le trouve et le corrige.",
|
||||
"starter.tests_title": "Lancer les tests",
|
||||
"starter.tests_desc": "Exécuter la suite de tests et expliquer les échecs.",
|
||||
|
||||
"tool.read": "Lire des fichiers",
|
||||
"tool.write": "Écrire et modifier",
|
||||
"tool.search": "Rechercher dans le code",
|
||||
"tool.run": "Exécuter des commandes autorisées",
|
||||
"tool.subagents": "Sous-agents",
|
||||
|
||||
"explore.tests": "Écrire des tests pour…",
|
||||
"explore.review": "Relire du code",
|
||||
"explore.explain": "Expliquer un fichier",
|
||||
"explore.document": "Rédiger un document",
|
||||
"explore.refactor": "Refactoriser",
|
||||
|
||||
"composer.placeholder": "Demandez à enkly de construire, corriger ou expliquer n'importe quoi…",
|
||||
"composer.send": "Envoyer",
|
||||
"composer.attach": "Ajouter des fichiers ou des photos",
|
||||
"composer.hint_send": "envoyer",
|
||||
"composer.hint_newline": "nouvelle ligne",
|
||||
"composer.hint_drop": "Déposez des fichiers, collez des captures d'écran ou ajoutez un projet .zip",
|
||||
|
||||
"auth.login_title": "Se connecter",
|
||||
"auth.login_button": "Se connecter",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Mot de passe",
|
||||
"auth.confirm_password_label": "Confirmer le mot de passe",
|
||||
"auth.no_account": "Vous n'avez pas de compte ?",
|
||||
"auth.signup_link": "S'inscrire",
|
||||
"auth.signup_title": "Créer votre compte",
|
||||
"auth.signup_button": "S'inscrire",
|
||||
"auth.have_account": "Vous avez déjà un compte ?",
|
||||
"auth.login_link": "Se connecter",
|
||||
"auth.forgot_link": "Mot de passe oublié ?",
|
||||
"auth.verify_title": "Vérifiez votre e-mail",
|
||||
"auth.verify_code_label": "Code à 6 chiffres",
|
||||
"auth.verify_button": "Vérifier",
|
||||
"auth.resend_link": "Renvoyer le code",
|
||||
"auth.forgot_title": "Réinitialiser votre mot de passe",
|
||||
"auth.forgot_button": "Envoyer le lien de réinitialisation",
|
||||
"auth.back_to_login": "Retour à la connexion",
|
||||
"auth.reset_title": "Choisissez un nouveau mot de passe",
|
||||
"auth.reset_button": "Réinitialiser le mot de passe",
|
||||
|
||||
"errors.generic": "Une erreur est survenue. Veuillez réessayer.",
|
||||
"errors.invalid_email": "Saisissez une adresse e-mail valide.",
|
||||
"errors.password_min": "Le mot de passe doit comporter au moins %d caractères.",
|
||||
"errors.password_max": "Le mot de passe doit comporter au plus %d caractères.",
|
||||
"errors.password_mismatch": "Les mots de passe ne correspondent pas.",
|
||||
"errors.incorrect_credentials": "E-mail ou mot de passe incorrect.",
|
||||
"errors.invalid_code": "Saisissez le code à 6 chiffres.",
|
||||
"errors.code_incorrect": "Ce code est incorrect ou a expiré.",
|
||||
"errors.reset_link_invalid": "Ce lien de réinitialisation est invalide ou a expiré. Demandez-en un nouveau.",
|
||||
"errors.csrf": "Jeton de sécurité invalide ou manquant — rechargez la page et réessayez.",
|
||||
|
||||
"info.check_inbox": "Consultez votre boîte de réception pour le code de vérification à 6 chiffres.",
|
||||
"info.mail_not_configured": "La messagerie n'est pas configurée sur ce serveur — consultez le journal du serveur pour votre code de vérification.",
|
||||
"info.resend_sent": "Si ce compte doit être vérifié, un nouveau code est en route.",
|
||||
"info.email_verified": "Votre e-mail est vérifié — connectez-vous pour continuer.",
|
||||
"info.password_reset_done": "Votre mot de passe a été réinitialisé — connectez-vous avec votre nouveau mot de passe.",
|
||||
"info.forgot_sent": "Si cette adresse possède un compte, un lien de réinitialisation est en route.",
|
||||
|
||||
"chat.type_message_first": "Saisissez d'abord un message.",
|
||||
"chat.message_too_long": "Ce message est trop long (limite de %d caractères).",
|
||||
"chat.rate_limited": "Trop de requêtes — ralentissez un peu.",
|
||||
"chat.conversation_gone": "Cette conversation n'existe plus. Démarrez une nouvelle discussion.",
|
||||
"chat.conversation_busy": "enkly est encore en train de répondre dans cette conversation — attendez la fin, ou appuyez sur Arrêter.",
|
||||
"chat.too_many_streams": "Vous avez déjà plusieurs réponses en cours. Attendez qu'une se termine ou appuyez sur Arrêter.",
|
||||
"chat.save_failed": "Impossible d'enregistrer votre message. Veuillez réessayer.",
|
||||
"chat.load_failed": "Impossible de charger la conversation. Veuillez réessayer.",
|
||||
"chat.send_failed": "Une erreur est survenue. Veuillez réessayer.",
|
||||
|
||||
"billing.quota_exceeded": "Vous avez atteint la limite du forfait %s : %d messages par jour. Passez à un forfait supérieur depuis la page Abonnement pour en obtenir plus."
|
||||
}
|
||||
74
pkg/i18n/locales/pt.json
Normal file
74
pkg/i18n/locales/pt.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"nav.new_chat": "Nova conversa",
|
||||
"nav.conversations": "Conversas",
|
||||
"nav.no_conversations": "Ainda não há conversas.",
|
||||
"nav.plan_suffix": "plano",
|
||||
"nav.manage": "Gerenciar",
|
||||
"nav.log_out": "Sair",
|
||||
"nav.theme_light": "Claro",
|
||||
"nav.theme_dark": "Escuro",
|
||||
"nav.theme_auto": "Automático",
|
||||
"nav.language": "Idioma",
|
||||
"nav.search_placeholder": "Pesquisar conversas, arquivos, ações…",
|
||||
"nav.agent_ready": "Agente pronto",
|
||||
"nav.rename_save": "Salvar nome",
|
||||
"nav.delete": "Excluir",
|
||||
"nav.close_sidebar": "Fechar barra lateral",
|
||||
"nav.open_sidebar": "Abrir barra lateral",
|
||||
|
||||
"hero.title": "O que vamos construir hoje?",
|
||||
"hero.subtitle": "enkly trabalha dentro do seu espaço de trabalho: lê, pesquisa, edita e executa código, mostrando cada etapa.",
|
||||
"hero.start_with": "Comece com",
|
||||
"hero.tools_heading": "O que o enkly pode usar",
|
||||
"hero.explore_heading": "Mais para explorar",
|
||||
|
||||
"starter.tour_title": "Conhecer este espaço de trabalho",
|
||||
"starter.tour_desc": "Mapeie as pastas, serviços e pontos de entrada.",
|
||||
"starter.trace_title": "Rastrear uma funcionalidade",
|
||||
"starter.trace_desc": "Acompanhe uma requisição pelo código.",
|
||||
"starter.fix_title": "Corrigir um bug",
|
||||
"starter.fix_desc": "Descreva-o, o enkly encontra e corrige.",
|
||||
"starter.tests_title": "Executar os testes",
|
||||
"starter.tests_desc": "Execute a suíte e explique o que falhou.",
|
||||
|
||||
"tool.read": "Ler arquivos",
|
||||
"tool.write": "Escrever e editar",
|
||||
"tool.search": "Pesquisar no código",
|
||||
"tool.run": "Executar comandos permitidos",
|
||||
"tool.subagents": "Subagentes",
|
||||
|
||||
"explore.tests": "Escrever testes para…",
|
||||
"explore.review": "Revisar código",
|
||||
"explore.explain": "Explicar um arquivo",
|
||||
"explore.document": "Redigir um documento",
|
||||
"explore.refactor": "Refatorar",
|
||||
|
||||
"composer.placeholder": "Peça ao enkly para construir, corrigir ou explicar qualquer coisa…",
|
||||
"composer.send": "Enviar",
|
||||
"composer.attach": "Adicionar arquivos ou fotos",
|
||||
"composer.hint_send": "enviar",
|
||||
"composer.hint_newline": "nova linha",
|
||||
"composer.hint_drop": "Solte arquivos, cole capturas de tela ou adicione um projeto .zip",
|
||||
|
||||
"auth.login_title": "Entrar",
|
||||
"auth.login_button": "Entrar",
|
||||
"auth.email_label": "E-mail",
|
||||
"auth.password_label": "Senha",
|
||||
"auth.confirm_password_label": "Confirmar senha",
|
||||
"auth.no_account": "Não tem uma conta?",
|
||||
"auth.signup_link": "Cadastre-se",
|
||||
"auth.signup_title": "Crie sua conta",
|
||||
"auth.signup_button": "Cadastrar",
|
||||
"auth.have_account": "Já tem uma conta?",
|
||||
"auth.login_link": "Entrar",
|
||||
"auth.forgot_link": "Esqueceu sua senha?",
|
||||
"auth.verify_title": "Verifique seu e-mail",
|
||||
"auth.verify_code_label": "Código de 6 dígitos",
|
||||
"auth.verify_button": "Verificar",
|
||||
"auth.resend_link": "Reenviar código",
|
||||
"auth.forgot_title": "Redefinir sua senha",
|
||||
"auth.forgot_button": "Enviar link de redefinição",
|
||||
"auth.back_to_login": "Voltar para o login",
|
||||
"auth.reset_title": "Escolha uma nova senha",
|
||||
"auth.reset_button": "Redefinir senha"
|
||||
}
|
||||
64
pkg/i18n/negotiate.go
Normal file
64
pkg/i18n/negotiate.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// weightedTag is one entry of a parsed Accept-Language header.
|
||||
type weightedTag struct {
|
||||
primary string // "fr" out of "fr-FR"
|
||||
q float64
|
||||
}
|
||||
|
||||
// parseAcceptLanguage turns a raw "Accept-Language" header value (RFC
|
||||
// 9110 §12.5.4 — e.g. "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7") into its
|
||||
// tags' primary subtags, ordered by descending quality weight. A
|
||||
// malformed segment is skipped rather than aborting the whole parse —
|
||||
// browsers have been known to send odd values, and the worst case of
|
||||
// ignoring one bad segment is falling back to Default, never a panic
|
||||
// or a rejected request.
|
||||
func parseAcceptLanguage(header string) []weightedTag {
|
||||
var tags []weightedTag
|
||||
for _, part := range strings.Split(header, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
tag, qStr, hasQ := strings.Cut(part, ";")
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag == "" || tag == "*" {
|
||||
continue
|
||||
}
|
||||
q := 1.0
|
||||
if hasQ {
|
||||
qStr = strings.TrimSpace(qStr)
|
||||
if v, ok := strings.CutPrefix(qStr, "q="); ok {
|
||||
if parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
||||
q = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
primary, _, _ := strings.Cut(tag, "-")
|
||||
tags = append(tags, weightedTag{primary: strings.ToLower(primary), q: q})
|
||||
}
|
||||
sort.SliceStable(tags, func(i, j int) bool { return tags[i].q > tags[j].q })
|
||||
return tags
|
||||
}
|
||||
|
||||
// NegotiateAcceptLanguage picks the best Supported locale for a raw
|
||||
// "Accept-Language" header value, falling back to Default if nothing
|
||||
// in the header is one enkly ships a catalog for (or the header is
|
||||
// empty / unparseable). Callers only reach for this when there's no
|
||||
// stronger signal available — see the gateway's localeFrom, which
|
||||
// tries the enkly_lang cookie first and only falls through to this
|
||||
// for a visitor who has never touched the language switcher.
|
||||
func NegotiateAcceptLanguage(header string) Locale {
|
||||
for _, tag := range parseAcceptLanguage(header) {
|
||||
if IsSupported(tag.primary) {
|
||||
return Locale(tag.primary)
|
||||
}
|
||||
}
|
||||
return Default
|
||||
}
|
||||
20
pkg/i18n/negotiate_test.go
Normal file
20
pkg/i18n/negotiate_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package i18n
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNegotiateAcceptLanguage(t *testing.T) {
|
||||
cases := map[string]Locale{
|
||||
"": Default,
|
||||
"fr-FR,fr;q=0.9,en;q=0.8": "fr",
|
||||
"de;q=0.5,fr;q=0.9": "fr",
|
||||
"xx-XX,yy-YY": Default,
|
||||
"pt-BR": "pt",
|
||||
"en-US,en;q=0.9": "en",
|
||||
" , ;q=bogus , es-MX;q=0.4": "es",
|
||||
}
|
||||
for header, want := range cases {
|
||||
if got := NegotiateAcceptLanguage(header); got != want {
|
||||
t.Errorf("NegotiateAcceptLanguage(%q) = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user