2525 initOnce sync.Once
2626)
2727
28+ // defaultLanguageVariants maps language codes without regions to their default regional variants.
29+ // This is used when a language without a base file is requested.
30+ var defaultLanguageVariants = map [string ]string {
31+ "pt" : "pt-BR" , // Portuguese defaults to Brazilian Portuguese for backward compatibility
32+ // Note: We currently have base files for these languages, but if we add regional variants
33+ // in the future, these defaults will be used:
34+ // "de": "de-DE", // German would default to Germany German
35+ // "en": "en-US", // English would default to US English
36+ // "es": "es-ES", // Spanish would default to Spain Spanish
37+ // "fa": "fa-IR", // Persian would default to Iran Persian
38+ // "fr": "fr-FR", // French would default to France French
39+ // "it": "it-IT", // Italian would default to Italy Italian
40+ // "ja": "ja-JP", // Japanese would default to Japan Japanese
41+ // "zh": "zh-CN", // Chinese would default to Simplified Chinese
42+ }
43+
2844// Init initializes the i18n bundle and localizer. It loads the specified locale
2945// and falls back to English if loading fails.
3046// Translation files are searched in the user config directory and downloaded
@@ -35,26 +51,30 @@ var (
3551func Init (locale string ) (* i18n.Localizer , error ) {
3652 // Use preferred locale detection if no explicit locale provided
3753 locale = getPreferredLocale (locale )
54+ // Normalize the locale to BCP 47 format (with hyphens)
55+ locale = normalizeToBCP47 (locale )
3856 if locale == "" {
3957 locale = "en"
4058 }
4159
4260 bundle := i18n .NewBundle (language .English )
4361 bundle .RegisterUnmarshalFunc ("json" , json .Unmarshal )
4462
45- // load embedded translations for the requested locale if available
63+ // Build a list of locale candidates to try
64+ locales := getLocaleCandidates (locale )
65+
66+ // Try to load embedded translations for each candidate
4667 embedded := false
47- if data , err := localeFS .ReadFile ("locales/" + locale + ".json" ); err == nil {
48- _ , _ = bundle .ParseMessageFileBytes (data , locale + ".json" )
49- embedded = true
50- } else if strings .Contains (locale , "-" ) {
51- // Try base language if regional variant not found (e.g., es-ES -> es)
52- baseLang := strings .Split (locale , "-" )[0 ]
53- if data , err := localeFS .ReadFile ("locales/" + baseLang + ".json" ); err == nil {
54- _ , _ = bundle .ParseMessageFileBytes (data , baseLang + ".json" )
68+ for _ , candidate := range locales {
69+ if data , err := localeFS .ReadFile ("locales/" + candidate + ".json" ); err == nil {
70+ _ , _ = bundle .ParseMessageFileBytes (data , candidate + ".json" )
5571 embedded = true
72+ locale = candidate // Update locale to what was actually loaded
73+ break
5674 }
5775 }
76+
77+ // Fall back to English if nothing was loaded
5878 if ! embedded {
5979 if data , err := localeFS .ReadFile ("locales/en.json" ); err == nil {
6080 _ , _ = bundle .ParseMessageFileBytes (data , "en.json" )
@@ -158,3 +178,63 @@ func tryGetMessage(locale, messageID string) string {
158178 }
159179 return ""
160180}
181+
182+ // normalizeToBCP47 normalizes a locale string to BCP 47 format.
183+ // Converts underscores to hyphens and ensures proper casing (language-REGION).
184+ func normalizeToBCP47 (locale string ) string {
185+ if locale == "" {
186+ return ""
187+ }
188+
189+ // Replace underscores with hyphens
190+ locale = strings .ReplaceAll (locale , "_" , "-" )
191+
192+ // Split into parts
193+ parts := strings .Split (locale , "-" )
194+ if len (parts ) == 1 {
195+ // Language only, lowercase it
196+ return strings .ToLower (parts [0 ])
197+ } else if len (parts ) >= 2 {
198+ // Language and region (and possibly more)
199+ // Lowercase language, uppercase region
200+ parts [0 ] = strings .ToLower (parts [0 ])
201+ parts [1 ] = strings .ToUpper (parts [1 ])
202+ return strings .Join (parts [:2 ], "-" ) // Return only language-REGION
203+ }
204+
205+ return locale
206+ }
207+
208+ // getLocaleCandidates returns a list of locale candidates to try, in order of preference.
209+ // For example, for "pt-PT" it returns ["pt-PT", "pt", "pt-BR"] (where pt-BR is the default for pt).
210+ func getLocaleCandidates (locale string ) []string {
211+ candidates := []string {}
212+
213+ if locale == "" {
214+ return candidates
215+ }
216+
217+ // First candidate is always the requested locale
218+ candidates = append (candidates , locale )
219+
220+ // If it's a regional variant, add the base language as a candidate
221+ if strings .Contains (locale , "-" ) {
222+ baseLang := strings .Split (locale , "-" )[0 ]
223+ candidates = append (candidates , baseLang )
224+
225+ // Also check if the base language has a default variant
226+ if defaultVariant , exists := defaultLanguageVariants [baseLang ]; exists {
227+ // Only add if it's different from what we already have
228+ if defaultVariant != locale {
229+ candidates = append (candidates , defaultVariant )
230+ }
231+ }
232+ } else {
233+ // If this is a base language without a region, check for default variant
234+ if defaultVariant , exists := defaultLanguageVariants [locale ]; exists {
235+ candidates = append (candidates , defaultVariant )
236+ }
237+ }
238+
239+ return candidates
240+ }
0 commit comments