Files
Fabric/internal/i18n/i18n.go
Kayvan Sylvan b34112d7ed feat(i18n): add i18n support for language variants (pt-BR/pt-PT)
• Add Brazilian Portuguese (pt-BR) translation file
• Add European Portuguese (pt-PT) translation file
• Implement BCP 47 locale normalization system
• Create fallback chain for language variants
• Add default variant mapping for Portuguese
• Update help text to show variant examples
• Add comprehensive test suite for variants
• Create documentation for i18n variant architecture
2025-09-21 16:04:59 -07:00

241 lines
7.3 KiB
Go

package i18n
import (
"embed"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
// embedded default locales
//
//go:embed locales/*.json
var localeFS embed.FS
var (
translator *i18n.Localizer
initOnce sync.Once
)
// defaultLanguageVariants maps language codes without regions to their default regional variants.
// This is used when a language without a base file is requested.
var defaultLanguageVariants = map[string]string{
"pt": "pt-BR", // Portuguese defaults to Brazilian Portuguese for backward compatibility
// Note: We currently have base files for these languages, but if we add regional variants
// in the future, these defaults will be used:
// "de": "de-DE", // German would default to Germany German
// "en": "en-US", // English would default to US English
// "es": "es-ES", // Spanish would default to Spain Spanish
// "fa": "fa-IR", // Persian would default to Iran Persian
// "fr": "fr-FR", // French would default to France French
// "it": "it-IT", // Italian would default to Italy Italian
// "ja": "ja-JP", // Japanese would default to Japan Japanese
// "zh": "zh-CN", // Chinese would default to Simplified Chinese
}
// Init initializes the i18n bundle and localizer. It loads the specified locale
// and falls back to English if loading fails.
// Translation files are searched in the user config directory and downloaded
// from GitHub if missing.
//
// If locale is empty, it will attempt to detect the system locale from
// environment variables (LC_ALL, LC_MESSAGES, LANG) following POSIX standards.
func Init(locale string) (*i18n.Localizer, error) {
// Use preferred locale detection if no explicit locale provided
locale = getPreferredLocale(locale)
// Normalize the locale to BCP 47 format (with hyphens)
locale = normalizeToBCP47(locale)
if locale == "" {
locale = "en"
}
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Build a list of locale candidates to try
locales := getLocaleCandidates(locale)
// Try to load embedded translations for each candidate
embedded := false
for _, candidate := range locales {
if data, err := localeFS.ReadFile("locales/" + candidate + ".json"); err == nil {
_, _ = bundle.ParseMessageFileBytes(data, candidate+".json")
embedded = true
locale = candidate // Update locale to what was actually loaded
break
}
}
// Fall back to English if nothing was loaded
if !embedded {
if data, err := localeFS.ReadFile("locales/en.json"); err == nil {
_, _ = bundle.ParseMessageFileBytes(data, "en.json")
}
}
// load locale from disk or download when not embedded
path := filepath.Join(userLocaleDir(), locale+".json")
if _, err := os.Stat(path); os.IsNotExist(err) && !embedded {
if err := downloadLocale(path, locale); err != nil {
// if download fails, still continue with embedded translations
fmt.Fprintf(os.Stderr, "%s\n", fmt.Sprintf(getErrorMessage("i18n_download_failed", "Failed to download translation for language '%s': %v"), locale, err))
}
}
if _, err := os.Stat(path); err == nil {
if _, err := bundle.LoadMessageFile(path); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", fmt.Sprintf(getErrorMessage("i18n_load_failed", "Failed to load translation file: %v"), err))
}
}
translator = i18n.NewLocalizer(bundle, locale)
return translator, nil
}
// T returns the localized string for the given message id.
// If the translator is not initialized, it will automatically initialize
// with system locale detection.
func T(messageID string) string {
initOnce.Do(func() {
if translator == nil {
Init("") // Empty string triggers system locale detection
}
})
return translator.MustLocalize(&i18n.LocalizeConfig{MessageID: messageID})
}
func userLocaleDir() string {
dir, err := os.UserConfigDir()
if err != nil {
dir = "."
}
path := filepath.Join(dir, "fabric", "locales")
os.MkdirAll(path, 0o755)
return path
}
func downloadLocale(path, locale string) error {
url := fmt.Sprintf("https://raw.githubusercontent.com/danielmiessler/Fabric/main/internal/i18n/locales/%s.json", locale)
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
return err
}
// getErrorMessage tries to get a translated error message, falling back to system locale
// and then to the provided fallback message. This is used during initialization when
// the translator may not be fully ready.
func getErrorMessage(messageID, fallback string) string {
// Try to get system locale for error messages
systemLocale := getPreferredLocale("")
if systemLocale == "" {
systemLocale = "en"
}
// First try the system locale
if msg := tryGetMessage(systemLocale, messageID); msg != "" {
return msg
}
// Fall back to English
if systemLocale != "en" {
if msg := tryGetMessage("en", messageID); msg != "" {
return msg
}
}
// Final fallback to hardcoded message
return fallback
}
// tryGetMessage attempts to get a message from embedded locale files
func tryGetMessage(locale, messageID string) string {
if data, err := localeFS.ReadFile("locales/" + locale + ".json"); err == nil {
var messages map[string]string
if json.Unmarshal(data, &messages) == nil {
if msg, exists := messages[messageID]; exists {
return msg
}
}
}
return ""
}
// normalizeToBCP47 normalizes a locale string to BCP 47 format.
// Converts underscores to hyphens and ensures proper casing (language-REGION).
func normalizeToBCP47(locale string) string {
if locale == "" {
return ""
}
// Replace underscores with hyphens
locale = strings.ReplaceAll(locale, "_", "-")
// Split into parts
parts := strings.Split(locale, "-")
if len(parts) == 1 {
// Language only, lowercase it
return strings.ToLower(parts[0])
} else if len(parts) >= 2 {
// Language and region (and possibly more)
// Lowercase language, uppercase region
parts[0] = strings.ToLower(parts[0])
parts[1] = strings.ToUpper(parts[1])
return strings.Join(parts[:2], "-") // Return only language-REGION
}
return locale
}
// getLocaleCandidates returns a list of locale candidates to try, in order of preference.
// For example, for "pt-PT" it returns ["pt-PT", "pt", "pt-BR"] (where pt-BR is the default for pt).
func getLocaleCandidates(locale string) []string {
candidates := []string{}
if locale == "" {
return candidates
}
// First candidate is always the requested locale
candidates = append(candidates, locale)
// If it's a regional variant, add the base language as a candidate
if strings.Contains(locale, "-") {
baseLang := strings.Split(locale, "-")[0]
candidates = append(candidates, baseLang)
// Also check if the base language has a default variant
if defaultVariant, exists := defaultLanguageVariants[baseLang]; exists {
// Only add if it's different from what we already have
if defaultVariant != locale {
candidates = append(candidates, defaultVariant)
}
}
} else {
// If this is a base language without a region, check for default variant
if defaultVariant, exists := defaultLanguageVariants[locale]; exists {
candidates = append(candidates, defaultVariant)
}
}
return candidates
}