Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

26
pkg/i18n/embed.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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)
}
}
}