i18n.go (16021B)
1 // Package i18n provides internalization and localization features. 2 package i18n 3 4 import ( 5 "net" 6 "net/http" 7 "os" 8 "strings" 9 "sync" 10 11 "github.com/kataras/i18n/internal" 12 13 "golang.org/x/net/publicsuffix" 14 "golang.org/x/text/language" 15 ) 16 17 // Default keeps a package-level pre-loaded `I18n` instance. 18 // The default glob pattern is "./locales/*/*" which accepts folder 19 // structure as: 20 // - ./locales 21 // - el-GR 22 // - filename.yaml 23 // - filename.toml 24 // - filename.json 25 // - en-US 26 // - ... 27 // - zh-CN 28 // - ... 29 // - ... 30 // 31 // The default language depends on the first lookup, please use the package-level `SetDefaultLanguage` 32 // to set a default language as you are not able to customize the language lists from here. 33 // 34 // See `New` package-level function to declare a fresh new, customized, `I18n` instance. 35 var Default *I18n 36 37 func init() { 38 Default, _ = New(Glob("./locales/*/*")) 39 } 40 41 // SetDefaultLanguage changes the default language of the `Default` `I18n` instance. 42 func SetDefaultLanguage(langCode string) bool { 43 return Default.SetDefault(langCode) 44 } 45 46 type ( 47 // Map is just an alias of the map[string]interface{} type. 48 Map = map[string]interface{} 49 50 // Locale is the type which the `Localizer.GetLocale` method returns. 51 // It serves the translations based on "key" or format. See its `GetMessage`. 52 Locale = internal.Locale 53 54 // MessageFunc is the function type to modify the behavior when a key or language was not found. 55 // All language inputs fallback to the default locale if not matched. 56 // This is why this signature accepts both input and matched languages, so caller 57 // can provide better messages. 58 // 59 // The first parameter is set to the client real input of the language, 60 // the second one is set to the matched language (default one if input wasn't matched) 61 // and the third and forth are the translation format/key and its optional arguments. 62 MessageFunc = internal.MessageFunc 63 64 // Loader accepts a `Matcher` and should return a `Localizer`. 65 // Functions that implement this type should load locale files. 66 Loader func(m *Matcher) (Localizer, error) 67 68 // Localizer is the interface which returned from a `Loader`. 69 // Types that implement this interface should be able to retrieve a `Locale` 70 // based on the language index. 71 Localizer interface { 72 // GetLocale should return a valid `Locale` based on the language index. 73 // It will always match the Loader.Matcher.Languages[index]. 74 // It may return the default language if nothing else matches based on custom localizer's criteria. 75 GetLocale(index int) *Locale 76 } 77 ) 78 79 // I18n is the structure which keeps the i18n configuration and implements Localization and internationalization features. 80 type I18n struct { 81 localizer Localizer 82 matcher *Matcher 83 84 loader Loader 85 mu sync.Mutex 86 87 // If not nil, this request's context key can be used to identify the current language. 88 // The found language(in this case, by path or subdomain) will be also filled with the current language on `Router` method. 89 ContextKey interface{} 90 // DefaultMessageFunc is the field which can be used 91 // to modify the behavior when a key or language was not found. 92 // All language inputs fallback to the default locale if not matched. 93 // This is why this one accepts both input and matched languages, 94 // so the caller can be more expressful knowing those. 95 // 96 // Defaults to nil. 97 DefaultMessageFunc MessageFunc 98 // ExtractFunc is the type signature for declaring custom logic 99 // to extract the language tag name. 100 ExtractFunc func(*http.Request) string 101 // If not empty, it is language identifier by url query. 102 URLParameter string 103 // If not empty, it is language identifier by cookie of this name. 104 Cookie string 105 // If true then a subdomain can be a language identifier too. 106 Subdomain bool 107 // If true then it will return empty string when translation for a a specific language's key was not found. 108 // Defaults to false, fallback defaultLang:key will be used. 109 Strict bool 110 } 111 112 // makeTags converts language codes to language Tags. 113 func makeTags(languages ...string) (tags []language.Tag) { 114 for _, lang := range languages { 115 tag, err := language.Parse(lang) 116 if err == nil && tag != language.Und { 117 tags = append(tags, tag) 118 } 119 } 120 121 return 122 } 123 124 // New returns a new `I18n` instance. 125 // It contains a `Router` wrapper to (local) redirect subdomains and path prefixes too. 126 // 127 // The "languages" input parameter is optional and if not empty then only these languages 128 // will be used for translations and the rest (if any) will be skipped. 129 // the first parameter of "loader" which lookups for translations inside files. 130 func New(loader Loader, languages ...string) (*I18n, error) { 131 tags := makeTags(languages...) 132 133 i := new(I18n) 134 i.loader = loader 135 i.matcher = &Matcher{ 136 strict: len(tags) > 0, 137 Languages: tags, 138 matcher: language.NewMatcher(tags), 139 defaultMessageFunc: i.DefaultMessageFunc, 140 } 141 142 if err := i.reload(); err != nil { 143 return nil, err 144 } 145 146 return i, nil 147 } 148 149 // reload loads the language files from the provided Loader, 150 // the `New` package-level function preloads those files already. 151 func (i *I18n) reload() error { // May be an exported function, if requested. 152 i.mu.Lock() 153 defer i.mu.Unlock() 154 155 localizer, err := i.loader(i.matcher) 156 if err != nil { 157 return err 158 } 159 160 i.localizer = localizer 161 return nil 162 } 163 164 // SetDefault changes the default language. 165 // Please avoid using this method; the default behavior will accept 166 // the first language of the registered tags as the default one. 167 func (i *I18n) SetDefault(langCode string) bool { 168 t, err := language.Parse(langCode) 169 if err != nil { 170 return false 171 } 172 173 if tag, index, conf := i.matcher.Match(t); conf > language.Low { 174 if l, ok := i.localizer.(interface { 175 SetDefault(int) bool 176 }); ok { 177 if l.SetDefault(index) { 178 tags := i.matcher.Languages 179 // set the order 180 tags[index] = tags[0] 181 tags[0] = tag 182 183 i.matcher.Languages = tags 184 i.matcher.matcher = language.NewMatcher(tags) 185 return true 186 } 187 } 188 } 189 190 return false 191 } 192 193 // Matcher implements the languae.Matcher. 194 // It contains the original language Matcher and keeps an ordered 195 // list of the registered languages for further use (see `Loader` implementation). 196 type Matcher struct { 197 strict bool 198 Languages []language.Tag 199 matcher language.Matcher 200 // defaultMessageFunc passed by the i18n structure. 201 defaultMessageFunc MessageFunc 202 } 203 204 var _ language.Matcher = (*Matcher)(nil) 205 206 // Match returns the best match for any of the given tags, along with 207 // a unique index associated with the returned tag and a confidence 208 // score. 209 func (m *Matcher) Match(t ...language.Tag) (language.Tag, int, language.Confidence) { 210 return m.matcher.Match(t...) 211 } 212 213 // MatchOrAdd acts like Match but it checks and adds a language tag, if not found, 214 // when the `Matcher.strict` field is true (when no tags are provided by the caller) 215 // and they should be dynamically added to the list. 216 func (m *Matcher) MatchOrAdd(t language.Tag) (tag language.Tag, index int, conf language.Confidence) { 217 tag, index, conf = m.Match(t) 218 if conf <= language.Low && !m.strict { 219 // not found, add it now. 220 m.Languages = append(m.Languages, t) 221 tag = t 222 index = len(m.Languages) - 1 223 conf = language.Exact 224 m.matcher = language.NewMatcher(m.Languages) // reset matcher to include the new language. 225 } 226 227 return 228 } 229 230 // ParseLanguageFiles returns a map of language indexes and 231 // their associated files based on the "fileNames". 232 func (m *Matcher) ParseLanguageFiles(fileNames []string) (map[int][]string, error) { 233 languageFiles := make(map[int][]string) 234 235 for _, fileName := range fileNames { 236 index := parsePath(m, fileName) 237 if index == -1 { 238 continue 239 } 240 241 languageFiles[index] = append(languageFiles[index], fileName) 242 } 243 244 return languageFiles, nil 245 } 246 247 func parsePath(m *Matcher, path string) int { 248 if t, ok := parseLanguage(path); ok { 249 if _, index, conf := m.MatchOrAdd(t); conf > language.Low { 250 return index 251 } 252 } 253 254 return -1 255 } 256 257 func parseLanguageName(m *Matcher, name string) int { 258 if t, err := language.Parse(name); err == nil { 259 if _, index, conf := m.MatchOrAdd(t); conf > language.Low { 260 return index 261 } 262 } 263 264 return -1 265 } 266 267 func reverseStrings(s []string) []string { 268 for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 269 s[i], s[j] = s[j], s[i] 270 } 271 return s 272 } 273 274 func parseLanguage(path string) (language.Tag, bool) { 275 if idx := strings.LastIndexByte(path, '.'); idx > 0 { 276 path = path[0:idx] 277 } 278 279 // path = strings.ReplaceAll(path, "..", "") 280 281 names := strings.FieldsFunc(path, func(r rune) bool { 282 return r == '_' || r == os.PathSeparator || r == '/' || r == '.' 283 }) 284 285 names = reverseStrings(names) // see https://github.com/kataras/i18n/issues/1 286 287 for _, s := range names { 288 t, err := language.Parse(s) 289 if err != nil { 290 continue 291 } 292 293 return t, true 294 } 295 296 return language.Und, false 297 } 298 299 // TryMatchString will try to match the "s" with a registered language tag. 300 // It returns -1 as the language index and false if not found. 301 func (i *I18n) TryMatchString(s string) (language.Tag, int, bool) { 302 if tag, err := language.Parse(s); err == nil { 303 if tag, index, conf := i.matcher.Match(tag); conf > language.Low { 304 return tag, index, true 305 } 306 } 307 308 return language.Und, -1, false 309 } 310 311 // Tr is package-level function which calls the `Default.Tr` method. 312 // 313 // See `I18n#Tr` method for more. 314 func Tr(lang, format string, args ...interface{}) string { 315 return Default.Tr(lang, format, args...) 316 } 317 318 // Tr returns a translated message based on the "lang" language code 319 // and its key(format) with any optional arguments attached to it. 320 // 321 // It returns an empty string if "lang" not matched, unless DefaultMessageFunc. 322 // It returns the default language's translation if "key" not matched, unless DefaultMessageFunc. 323 func (i *I18n) Tr(lang, format string, args ...interface{}) (msg string) { 324 _, index, ok := i.TryMatchString(lang) 325 if !ok { 326 index = 0 327 } 328 329 langMatched := "" 330 331 loc := i.localizer.GetLocale(index) 332 if loc != nil { 333 langMatched = loc.Language() 334 335 msg = loc.GetMessage(format, args...) 336 if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && index > 0 { 337 // it's not the default/fallback language and not message found for that lang:key. 338 msg = i.localizer.GetLocale(0).GetMessage(format, args...) 339 } 340 } 341 342 if msg == "" && i.DefaultMessageFunc != nil { 343 msg = i.DefaultMessageFunc(lang, langMatched, format, args...) 344 } 345 346 return 347 } 348 349 const acceptLanguageHeaderKey = "Accept-Language" 350 351 // GetLocale is package-level function which calls the `Default.GetLocale` method. 352 // 353 // See `I18n#GetLocale` method for more. 354 func GetLocale(r *http.Request) *Locale { 355 return Default.GetLocale(r) 356 } 357 358 // GetLocale returns the found locale of a request. 359 // It will return the first registered language if nothing else matched. 360 func (i *I18n) GetLocale(r *http.Request) *Locale { 361 var ( 362 index int 363 ok bool 364 ) 365 366 if i.ContextKey != nil { 367 if v := r.Context().Value(i.ContextKey); v != nil { 368 if s, isString := v.(string); isString { 369 if v == "default" { 370 index = 0 // no need to call `TryMatchString` and spend time. 371 } else { 372 _, index, _ = i.TryMatchString(s) 373 } 374 375 locale := i.localizer.GetLocale(index) 376 if locale == nil { 377 return nil 378 } 379 380 return locale 381 } 382 } 383 } 384 385 if !ok && i.ExtractFunc != nil { 386 if v := i.ExtractFunc(r); v != "" { 387 _, index, ok = i.TryMatchString(v) 388 } 389 } 390 391 if !ok && i.URLParameter != "" { 392 if v := r.URL.Query().Get(i.URLParameter); v != "" { 393 _, index, ok = i.TryMatchString(v) 394 } 395 } 396 397 if !ok && i.Cookie != "" { 398 cookie, err := r.Cookie(i.Cookie) 399 if err == nil { 400 _, index, ok = i.TryMatchString(cookie.Value) // url.QueryUnescape(cookie.Value) 401 } 402 } 403 404 if !ok && i.Subdomain { 405 if v, _ := getSubdomain(r); v != "" { 406 _, index, ok = i.TryMatchString(v) 407 } 408 } 409 410 if !ok { 411 if v := r.Header.Get(acceptLanguageHeaderKey); v != "" { 412 desired, _, err := language.ParseAcceptLanguage(v) 413 if err == nil { 414 if _, idx, conf := i.matcher.Match(desired...); conf > language.Low { 415 index = idx 416 } 417 } 418 } 419 } 420 421 // if index == 0 then it defaults to the first language. 422 locale := i.localizer.GetLocale(index) 423 if locale == nil { 424 return nil 425 } 426 427 return locale 428 } 429 430 // GetMessage is package-level function which calls the `Default.GetMessage` method. 431 // 432 // See `I18n#GetMessage` method for more. 433 func GetMessage(r *http.Request, format string, args ...interface{}) string { 434 return Default.GetMessage(r, format, args...) 435 } 436 437 // GetMessage returns the localized text message for this "r" request based on the key "format". 438 // It returns an empty string if locale or format not found. 439 func (i *I18n) GetMessage(r *http.Request, format string, args ...interface{}) (msg string) { 440 loc := i.GetLocale(r) 441 langMatched := "" 442 if loc != nil { 443 langMatched = loc.Language() 444 // it's not the default/fallback language and not message found for that lang:key. 445 msg = loc.GetMessage(format, args...) 446 if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && loc.Index() > 0 { 447 return i.localizer.GetLocale(0).GetMessage(format, args...) 448 } 449 } 450 451 if msg == "" && i.DefaultMessageFunc != nil && i.ContextKey != nil { 452 if v := r.Context().Value(i.ContextKey); v != nil { 453 if langInput, ok := v.(string); ok { 454 msg = i.DefaultMessageFunc(langInput, langMatched, format, args...) 455 } 456 } 457 } 458 459 return 460 } 461 462 // Router is package-level function which calls the `Default.Router` method. 463 // 464 // See `I18n#Router` method for more. 465 func Router(next http.Handler) http.Handler { 466 return Default.Router(next) 467 } 468 469 func (i *I18n) setLang(w http.ResponseWriter, r *http.Request, lang string) { 470 if i.Cookie != "" { 471 http.SetCookie(w, &http.Cookie{ 472 Name: i.Cookie, 473 Value: lang, 474 // allow subdomain sharing. 475 Domain: getDomain(getHost(r)), 476 SameSite: http.SameSiteLaxMode, 477 }) 478 } else if i.URLParameter != "" { 479 q := r.URL.Query() 480 q.Set(i.URLParameter, lang) 481 r.URL.RawQuery = q.Encode() 482 } 483 484 r.Header.Set(acceptLanguageHeaderKey, lang) 485 } 486 487 // Router returns a new router wrapper. 488 // It compares the path prefix for translated language and 489 // local redirects the requested path with the selected (from the path) language to the router. 490 func (i *I18n) Router(next http.Handler) http.Handler { 491 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 492 found := false 493 path := r.URL.Path[1:] 494 495 if idx := strings.IndexByte(path, '/'); idx > 0 { 496 path = path[:idx] 497 } 498 499 if path != "" { 500 if tag, _, ok := i.TryMatchString(path); ok { 501 lang := tag.String() 502 503 path = r.URL.Path[len(path)+1:] 504 if path == "" { 505 path = "/" 506 } 507 508 r.RequestURI = path 509 r.URL.Path = path 510 i.setLang(w, r, lang) 511 found = true 512 } 513 } 514 515 if !found && i.Subdomain { 516 host := getHost(r) 517 if dotIdx := strings.IndexByte(host, '.'); dotIdx > 0 { 518 if subdomain := host[0:dotIdx]; subdomain != "" { 519 if tag, _, ok := i.TryMatchString(subdomain); ok { 520 host = host[dotIdx+1:] 521 r.URL.Host = host 522 r.Host = host 523 i.setLang(w, r, tag.String()) 524 } 525 } 526 } 527 } 528 529 next.ServeHTTP(w, r) 530 }) 531 } 532 533 func getHost(r *http.Request) string { 534 // contains subdomain. 535 if host := r.URL.Host; host != "" { 536 return host 537 } 538 return r.Host 539 } 540 541 // GetDomain resolves and returns the server's domain. 542 func getDomain(hostport string) string { 543 host := hostport 544 if tmp, _, err := net.SplitHostPort(hostport); err == nil { 545 host = tmp 546 } 547 548 switch host { 549 // We could use the netutil.LoopbackRegex but leave it as it's for now, it's faster. 550 case "localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]", "0:0:0:0:0:0:0:0", "0:0:0:0:0:0:0:1": 551 // loopback. 552 return "localhost" 553 default: 554 if domain, err := publicsuffix.EffectiveTLDPlusOne(host); err == nil { 555 host = domain 556 } 557 558 return host 559 } 560 } 561 562 func getSubdomain(r *http.Request) (subdomain, host string) { 563 host = getHost(r) 564 565 if index := strings.IndexByte(host, '.'); index > 0 { 566 if subdomain = host[0:index]; subdomain != "" { 567 host = host[index+1:] 568 } 569 } 570 571 return 572 }