cli/pkg/i18n/i18n.go

238 lines
6.6 KiB
Go

// Package i18n provides internationalization and localization services.
//
// It is designed to be a simple, straightforward i18n solution that should be
// well-suited to most applications.
//
// # Getting Started
//
// To use the i18n service, you first need to create a new instance:
//
// i18nService, err := i18n.New()
// if err != nil {
// log.Fatal(err)
// }
//
// Once you have a service instance, you can set the language and translate
// messages.
//
// # Locales
//
// The i18n service loads locales from the `locales` directory. Locales are JSON
// files with the language code as the filename (e.g., `en.json`, `es.json`).
// The service uses the `embed` package to bundle the locales into the binary,
// so you don't need to worry about distributing the locale files with your
// application.
package i18n
import (
"embed"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed locales/*.json
var localeFS embed.FS
// Options holds configuration for the i18n service.
// This is a placeholder for future configuration options.
type Options struct{}
// Service provides internationalization and localization.
// It is the primary entrypoint for the i18n package.
type Service struct {
bundle *i18n.Bundle
localizer *i18n.Localizer
availableLangs []language.Tag
}
// newI18nService contains the common logic for initializing a Service struct.
func newI18nService() (*Service, error) {
bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
availableLangs, err := getAvailableLanguages()
if err != nil {
return nil, err
}
for _, lang := range availableLangs {
filePath := fmt.Sprintf("locales/%s.json", lang.String())
if _, err := bundle.LoadMessageFileFS(localeFS, filePath); err != nil {
return nil, fmt.Errorf("failed to load message file %s: %w", filePath, err)
}
}
s := &Service{
bundle: bundle,
availableLangs: availableLangs,
}
// Language will be set during ServiceStartup after config is available.
return s, nil
}
// New creates a new i18n service.
// The service is initialized with the English language as the default.
func New() (*Service, error) {
s, err := newI18nService()
if err != nil {
return nil, err
}
err = s.SetLanguage("en")
if err != nil {
return nil, err
}
return s, nil
}
// --- Language Management ---
func getAvailableLanguages() ([]language.Tag, error) {
files, err := localeFS.ReadDir("locales")
if err != nil {
return nil, fmt.Errorf("failed to read embedded locales directory: %w", err)
}
var availableLangs []language.Tag
for _, file := range files {
lang := strings.TrimSuffix(file.Name(), ".json")
tag := language.Make(lang)
availableLangs = append(availableLangs, tag)
}
return availableLangs, nil
}
func detectLanguage(supported []language.Tag) (string, error) {
langEnv := os.Getenv("LANG")
if langEnv == "" {
return "", nil
}
baseLang := strings.Split(langEnv, ".")[0]
parsedLang, err := language.Parse(baseLang)
if err != nil {
return "", fmt.Errorf("failed to parse language tag '%s': %w", baseLang, err)
}
if len(supported) == 0 {
return "", nil
}
matcher := language.NewMatcher(supported)
_, index, confidence := matcher.Match(parsedLang)
if confidence >= language.Low {
return supported[index].String(), nil
}
return "", nil
}
// --- Public Service Methods ---
// SetLanguage sets the language for the i18n service.
// The language tag should be a valid BCP 47 language tag (e.g., "en", "en-US").
// If the language is not supported, an error is returned.
func (s *Service) SetLanguage(lang string) error {
requestedLang, err := language.Parse(lang)
if err != nil {
return fmt.Errorf("i18n: failed to parse language tag \"%s\": %w", lang, err)
}
if len(s.availableLangs) == 0 {
return fmt.Errorf("i18n: no available languages loaded in the bundle")
}
matcher := language.NewMatcher(s.availableLangs)
bestMatch, _, confidence := matcher.Match(requestedLang)
if confidence == language.No {
return fmt.Errorf("i18n: unsupported language: %s", lang)
}
s.localizer = i18n.NewLocalizer(s.bundle, bestMatch.String())
return nil
}
// Translate translates a message by its ID.
// It accepts an optional template data argument to interpolate into the translation.
// If the message is not found, the message ID is returned.
func (s *Service) Translate(messageID string, args ...interface{}) string {
config := &i18n.LocalizeConfig{MessageID: messageID}
if len(args) > 0 {
config.TemplateData = args[0]
if len(args) > 1 {
fmt.Fprintf(os.Stderr, "i18n: Translate called with %d arguments, expected at most 1 (template data)\n", len(args))
}
}
translation, err := s.localizer.Localize(config)
if err != nil {
fmt.Fprintf(os.Stderr, "i18n: translation for key \"%s\" not found\n", messageID)
return messageID
}
return translation
}
// SetBundle is a test helper to inject a bundle.
func (s *Service) SetBundle(bundle *i18n.Bundle) {
s.bundle = bundle
}
// AvailableLanguages returns a list of available language codes.
func (s *Service) AvailableLanguages() []string {
langs := make([]string, len(s.availableLangs))
for i, tag := range s.availableLangs {
langs[i] = tag.String()
}
return langs
}
// GetAllMessages returns all translation messages for the specified language.
// The keys are message IDs and values are the translated strings.
// If lang is empty, it uses the current language.
func (s *Service) GetAllMessages(lang string) (map[string]string, error) {
messages := make(map[string]string)
// Default to English if no language specified
if lang == "" {
lang = "en"
}
// Try to find the locale file for the specified language
filePath := fmt.Sprintf("locales/%s.json", lang)
data, err := localeFS.ReadFile(filePath)
if err != nil {
// Try without region code (e.g., "en-US" -> "en")
if strings.Contains(lang, "-") {
baseLang := strings.Split(lang, "-")[0]
filePath = fmt.Sprintf("locales/%s.json", baseLang)
data, err = localeFS.ReadFile(filePath)
}
if err != nil {
return nil, fmt.Errorf("failed to read locale file for language %s: %w", lang, err)
}
}
var rawMessages map[string]interface{}
if err := json.Unmarshal(data, &rawMessages); err != nil {
return nil, fmt.Errorf("failed to parse locale file: %w", err)
}
// Extract messages - handle both simple strings and complex message objects
for key, value := range rawMessages {
switch v := value.(type) {
case string:
messages[key] = v
case map[string]interface{}:
if other, ok := v["other"].(string); ok {
messages[key] = other
}
}
}
return messages, nil
}