i18n.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. // Package i18n provides internalization and localization features for Iris.
  2. // To use with net/http see https://github.com/kataras/i18n instead.
  3. package i18n
  4. import (
  5. "fmt"
  6. "io/fs"
  7. "net/http"
  8. "os"
  9. "strings"
  10. "sync"
  11. "github.com/kataras/iris/v12/context"
  12. "github.com/kataras/iris/v12/core/router"
  13. "github.com/kataras/iris/v12/i18n/internal"
  14. "golang.org/x/text/language"
  15. )
  16. type (
  17. // MessageFunc is the function type to modify the behavior when a key or language was not found.
  18. // All language inputs fallback to the default locale if not matched.
  19. // This is why this signature accepts both input and matched languages, so caller
  20. // can provide better messages.
  21. //
  22. // The first parameter is set to the client real input of the language,
  23. // the second one is set to the matched language (default one if input wasn't matched)
  24. // and the third and forth are the translation format/key and its optional arguments.
  25. //
  26. // Note: we don't accept the Context here because Tr method and template func {{ tr }}
  27. // have no direct access to it.
  28. MessageFunc = internal.MessageFunc
  29. // Loader accepts a `Matcher` and should return a `Localizer`.
  30. // Functions that implement this type should load locale files.
  31. Loader func(m *Matcher) (Localizer, error)
  32. // Localizer is the interface which returned from a `Loader`.
  33. // Types that implement this interface should be able to retrieve a `Locale`
  34. // based on the language index.
  35. Localizer interface {
  36. // GetLocale should return a valid `Locale` based on the language index.
  37. // It will always match the Loader.Matcher.Languages[index].
  38. // It may return the default language if nothing else matches based on custom localizer's criteria.
  39. GetLocale(index int) context.Locale
  40. }
  41. )
  42. // I18n is the structure which keeps the i18n configuration and implements localization and internationalization features.
  43. type I18n struct {
  44. localizer Localizer
  45. matcher *Matcher
  46. Loader LoaderConfig
  47. loader Loader
  48. mu sync.Mutex
  49. // ExtractFunc is the type signature for declaring custom logic
  50. // to extract the language tag name.
  51. // Defaults to nil.
  52. ExtractFunc func(ctx *context.Context) string
  53. // DefaultMessageFunc is the field which can be used
  54. // 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 one accepts both input and matched languages,
  57. // so the caller can be more expressful knowing those.
  58. //
  59. // Defaults to nil.
  60. DefaultMessageFunc MessageFunc
  61. // If not empty, it is language identifier by url query.
  62. //
  63. // Defaults to "lang".
  64. URLParameter string
  65. // If not empty, it is language identifier by cookie of this name.
  66. //
  67. // Defaults to empty.
  68. Cookie string
  69. // If true then a subdomain can be a language identifier.
  70. //
  71. // Defaults to true.
  72. Subdomain bool
  73. // If a DefaultMessageFunc is NOT set:
  74. // If true then it will return empty string when translation for a
  75. // specific language's key was not found.
  76. // Defaults to false, fallback defaultLang:key will be used.
  77. // Otherwise, DefaultMessageFunc is called in either case.
  78. Strict bool
  79. // If true then Iris will wrap its router with the i18n router wrapper on its Build state.
  80. // It will (local) redirect requests like:
  81. // 1. /$lang_prefix/$path to /$path with the language set to $lang_prefix part.
  82. // 2. $lang_subdomain.$domain/$path to $domain/$path with the language set to $lang_subdomain part.
  83. //
  84. // Defaults to true.
  85. PathRedirect bool
  86. }
  87. var _ context.I18nReadOnly = (*I18n)(nil)
  88. // makeTags converts language codes to language Tags.
  89. func makeTags(languages ...string) (tags []language.Tag) {
  90. languages = removeDuplicates(languages)
  91. for _, lang := range languages {
  92. tag, err := language.Parse(lang)
  93. if err == nil && tag != language.Und {
  94. tags = append(tags, tag)
  95. }
  96. }
  97. return
  98. }
  99. // New returns a new `I18n` instance. Use its `Load` or `LoadAssets` to load languages.
  100. // Examples at: https://github.com/kataras/iris/tree/main/_examples/i18n.
  101. func New() *I18n {
  102. i := &I18n{
  103. Loader: DefaultLoaderConfig,
  104. URLParameter: "lang",
  105. Subdomain: true,
  106. PathRedirect: true,
  107. }
  108. return i
  109. }
  110. // Load is a method shortcut to load files using a filepath.Glob pattern.
  111. // It returns a non-nil error on failure.
  112. //
  113. // See `New` and `Glob` package-level functions for more.
  114. func (i *I18n) Load(globPattern string, languages ...string) error {
  115. return i.Reset(Glob(globPattern, i.Loader), languages...)
  116. }
  117. // LoadAssets is a method shortcut to load files using go-bindata.
  118. // It returns a non-nil error on failure.
  119. //
  120. // See `New` and `Asset` package-level functions for more.
  121. func (i *I18n) LoadAssets(assetNames func() []string, asset func(string) ([]byte, error), languages ...string) error {
  122. return i.Reset(Assets(assetNames, asset, i.Loader), languages...)
  123. }
  124. // LoadFS is a method shortcut to load files using
  125. // an `embed.FS` or `fs.FS` or `http.FileSystem` value.
  126. // The "pattern" is a classic glob pattern.
  127. //
  128. // See `New` and `FS` package-level functions for more.
  129. // Example: https://github.com/kataras/iris/blob/main/_examples/i18n/template-embedded/main.go.
  130. func (i *I18n) LoadFS(fileSystem fs.FS, pattern string, languages ...string) error {
  131. loader, err := FS(fileSystem, pattern, i.Loader)
  132. if err != nil {
  133. return err
  134. }
  135. return i.Reset(loader, languages...)
  136. }
  137. // LoadKV is a method shortcut to load locales from a map of specified languages.
  138. // See `KV` package-level function for more.
  139. func (i *I18n) LoadKV(langMap LangMap, languages ...string) error {
  140. loader := KV(langMap, i.Loader)
  141. return i.Reset(loader, languages...)
  142. }
  143. // Reset sets the locales loader and languages.
  144. // It is not meant to be used by users unless
  145. // a custom `Loader` must be used instead of the default one.
  146. func (i *I18n) Reset(loader Loader, languages ...string) error {
  147. tags := makeTags(languages...)
  148. i.loader = loader
  149. i.matcher = &Matcher{
  150. strict: len(tags) > 0,
  151. Languages: tags,
  152. matcher: language.NewMatcher(tags),
  153. defaultMessageFunc: i.DefaultMessageFunc,
  154. }
  155. return i.reload()
  156. }
  157. // reload loads the language files from the provided Loader,
  158. // the `New` package-level function preloads those files already.
  159. func (i *I18n) reload() error { // May be an exported function, if requested.
  160. i.mu.Lock()
  161. defer i.mu.Unlock()
  162. if i.loader == nil {
  163. return fmt.Errorf("nil loader")
  164. }
  165. localizer, err := i.loader(i.matcher)
  166. if err != nil {
  167. return err
  168. }
  169. i.localizer = localizer
  170. return nil
  171. }
  172. // Loaded reports whether `New` or `Load/LoadAssets` called.
  173. func (i *I18n) Loaded() bool {
  174. return i != nil && i.loader != nil && i.localizer != nil && i.matcher != nil
  175. }
  176. // Tags returns the registered languages or dynamically resolved by files.
  177. // Use `Load` or `LoadAssets` first.
  178. func (i *I18n) Tags() []language.Tag {
  179. if !i.Loaded() {
  180. return nil
  181. }
  182. return i.matcher.Languages
  183. }
  184. // SetDefault changes the default language.
  185. // Please avoid using this method; the default behavior will accept
  186. // the first language of the registered tags as the default one.
  187. func (i *I18n) SetDefault(langCode string) bool {
  188. t, err := language.Parse(langCode)
  189. if err != nil {
  190. return false
  191. }
  192. if tag, index, conf := i.matcher.Match(t); conf > language.Low {
  193. if l, ok := i.localizer.(interface {
  194. SetDefault(int) bool
  195. }); ok {
  196. if l.SetDefault(index) {
  197. tags := i.matcher.Languages
  198. // set the order
  199. tags[index] = tags[0]
  200. tags[0] = tag
  201. i.matcher.Languages = tags
  202. i.matcher.matcher = language.NewMatcher(tags)
  203. return true
  204. }
  205. }
  206. }
  207. return false
  208. }
  209. // Matcher implements the languae.Matcher.
  210. // It contains the original language Matcher and keeps an ordered
  211. // list of the registered languages for further use (see `Loader` implementation).
  212. type Matcher struct {
  213. strict bool
  214. Languages []language.Tag
  215. matcher language.Matcher
  216. // defaultMessageFunc passed by the i18n structure.
  217. defaultMessageFunc MessageFunc
  218. }
  219. var _ language.Matcher = (*Matcher)(nil)
  220. // Match returns the best match for any of the given tags, along with
  221. // a unique index associated with the returned tag and a confidence
  222. // score.
  223. func (m *Matcher) Match(t ...language.Tag) (language.Tag, int, language.Confidence) {
  224. return m.matcher.Match(t...)
  225. }
  226. // MatchOrAdd acts like Match but it checks and adds a language tag, if not found,
  227. // when the `Matcher.strict` field is true (when no tags are provided by the caller)
  228. // and they should be dynamically added to the list.
  229. func (m *Matcher) MatchOrAdd(t language.Tag) (tag language.Tag, index int, conf language.Confidence) {
  230. tag, index, conf = m.Match(t)
  231. if conf <= language.Low && !m.strict {
  232. // not found, add it now.
  233. m.Languages = append(m.Languages, t)
  234. tag = t
  235. index = len(m.Languages) - 1
  236. conf = language.Exact
  237. m.matcher = language.NewMatcher(m.Languages) // reset matcher to include the new language.
  238. }
  239. return
  240. }
  241. // ParseLanguageFiles returns a map of language indexes and
  242. // their associated files based on the "fileNames".
  243. func (m *Matcher) ParseLanguageFiles(fileNames []string) (map[int][]string, error) {
  244. languageFiles := make(map[int][]string)
  245. for _, fileName := range fileNames {
  246. index := parsePath(m, fileName)
  247. if index == -1 {
  248. continue
  249. }
  250. languageFiles[index] = append(languageFiles[index], fileName)
  251. }
  252. return languageFiles, nil
  253. }
  254. func parsePath(m *Matcher, path string) int {
  255. if t, ok := parseLanguage(path); ok {
  256. if _, index, conf := m.MatchOrAdd(t); conf > language.Low {
  257. return index
  258. }
  259. }
  260. return -1
  261. }
  262. func parseLanguageName(m *Matcher, name string) int {
  263. if t, err := language.Parse(name); err == nil {
  264. if _, index, conf := m.MatchOrAdd(t); conf > language.Low {
  265. return index
  266. }
  267. }
  268. return -1
  269. }
  270. func reverseStrings(s []string) []string {
  271. for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
  272. s[i], s[j] = s[j], s[i]
  273. }
  274. return s
  275. }
  276. func parseLanguage(path string) (language.Tag, bool) {
  277. if idx := strings.LastIndexByte(path, '.'); idx > 0 {
  278. path = path[0:idx]
  279. }
  280. // path = strings.ReplaceAll(path, "..", "")
  281. names := strings.FieldsFunc(path, func(r rune) bool {
  282. return r == '_' || r == os.PathSeparator || r == '/' || r == '.'
  283. })
  284. names = reverseStrings(names) // see https://github.com/kataras/i18n/issues/1
  285. for _, s := range names {
  286. t, err := language.Parse(s)
  287. if err != nil {
  288. continue
  289. }
  290. return t, true
  291. }
  292. return language.Und, false
  293. }
  294. // TryMatchString will try to match the "s" with a registered language tag.
  295. // It returns -1 as the language index and false if not found.
  296. func (i *I18n) TryMatchString(s string) (language.Tag, int, bool) {
  297. if tag, err := language.Parse(s); err == nil {
  298. if tag, index, conf := i.matcher.Match(tag); conf > language.Low {
  299. return tag, index, true
  300. }
  301. }
  302. return language.Und, -1, false
  303. }
  304. // Tr returns a translated message based on the "lang" language code
  305. // and its key with any optional arguments attached to it.
  306. //
  307. // It returns an empty string if "lang" not matched, unless DefaultMessageFunc.
  308. // It returns the default language's translation if "key" not matched, unless DefaultMessageFunc.
  309. func (i *I18n) Tr(lang, key string, args ...interface{}) string {
  310. _, index, ok := i.TryMatchString(lang)
  311. if !ok {
  312. index = 0
  313. }
  314. loc := i.localizer.GetLocale(index)
  315. return i.getLocaleMessage(loc, lang, key, args...)
  316. }
  317. // TrContext returns the localized text message for this Context.
  318. // It returns an empty string if context's locale not matched, unless DefaultMessageFunc.
  319. // It returns the default language's translation if "key" not matched, unless DefaultMessageFunc.
  320. func (i *I18n) TrContext(ctx *context.Context, key string, args ...interface{}) string {
  321. loc := ctx.GetLocale()
  322. langInput := ctx.Values().GetString(ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey())
  323. return i.getLocaleMessage(loc, langInput, key, args...)
  324. }
  325. func (i *I18n) getLocaleMessage(loc context.Locale, langInput string, key string, args ...interface{}) (msg string) {
  326. langMatched := ""
  327. if loc != nil {
  328. langMatched = loc.Language()
  329. msg = loc.GetMessage(key, args...)
  330. if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && loc.Index() > 0 {
  331. // it's not the default/fallback language and not message found for that lang:key.
  332. msg = i.localizer.GetLocale(0).GetMessage(key, args...)
  333. }
  334. }
  335. if msg == "" && i.DefaultMessageFunc != nil {
  336. msg = i.DefaultMessageFunc(langInput, langMatched, key, args...)
  337. }
  338. return
  339. }
  340. const acceptLanguageHeaderKey = "Accept-Language"
  341. // GetLocale returns the found locale of a request.
  342. // It will return the first registered language if nothing else matched.
  343. func (i *I18n) GetLocale(ctx *context.Context) context.Locale {
  344. var (
  345. index int
  346. ok bool
  347. extractedLang string
  348. )
  349. languageInputKey := ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey()
  350. if contextKey := ctx.Application().ConfigurationReadOnly().GetLanguageContextKey(); contextKey != "" {
  351. if v := ctx.Values().GetString(contextKey); v != "" {
  352. if languageInputKey != "" {
  353. ctx.Values().Set(languageInputKey, v)
  354. }
  355. if v == "default" {
  356. index = 0 // no need to call `TryMatchString` and spend time.
  357. } else {
  358. _, index, _ = i.TryMatchString(v)
  359. }
  360. locale := i.localizer.GetLocale(index)
  361. if locale == nil {
  362. return nil
  363. }
  364. return locale
  365. }
  366. }
  367. if !ok && i.ExtractFunc != nil {
  368. if v := i.ExtractFunc(ctx); v != "" {
  369. extractedLang = v
  370. _, index, ok = i.TryMatchString(v)
  371. }
  372. }
  373. if !ok && i.URLParameter != "" {
  374. if v := ctx.URLParam(i.URLParameter); v != "" {
  375. extractedLang = v
  376. _, index, ok = i.TryMatchString(v)
  377. }
  378. }
  379. if !ok && i.Cookie != "" {
  380. if v := ctx.GetCookie(i.Cookie); v != "" {
  381. extractedLang = v
  382. _, index, ok = i.TryMatchString(v) // url.QueryUnescape(cookie.Value)
  383. }
  384. }
  385. if !ok && i.Subdomain {
  386. if v := ctx.Subdomain(); v != "" {
  387. extractedLang = v
  388. _, index, ok = i.TryMatchString(v)
  389. }
  390. }
  391. if !ok {
  392. if v := ctx.GetHeader(acceptLanguageHeaderKey); v != "" {
  393. extractedLang = v // note.
  394. desired, _, err := language.ParseAcceptLanguage(v)
  395. if err == nil {
  396. if _, idx, conf := i.matcher.Match(desired...); conf > language.Low {
  397. index = idx
  398. }
  399. }
  400. }
  401. }
  402. // locale := i.localizer.GetLocale(index)
  403. // ctx.Values().Set(ctx.Application().ConfigurationReadOnly().GetLocaleContextKey(), locale)
  404. if languageInputKey != "" {
  405. // Set the user input we wanna use it on DefaultMessageFunc.
  406. // Even if matched because it may be en-gb or en but if there is a language registered
  407. // as en-us it will be successfully matched ( see TrymatchString and Low conf).
  408. ctx.Values().Set(languageInputKey, extractedLang)
  409. }
  410. // if index == 0 then it defaults to the first language.
  411. locale := i.localizer.GetLocale(index)
  412. if locale == nil {
  413. return nil
  414. }
  415. return locale
  416. }
  417. func (i *I18n) setLangWithoutContext(w http.ResponseWriter, r *http.Request, lang string) {
  418. if i.Cookie != "" {
  419. http.SetCookie(w, &http.Cookie{
  420. Name: i.Cookie,
  421. Value: lang,
  422. // allow subdomain sharing.
  423. Domain: context.GetDomain(context.GetHost(r)),
  424. SameSite: http.SameSiteLaxMode,
  425. })
  426. } else if i.URLParameter != "" {
  427. q := r.URL.Query()
  428. q.Set(i.URLParameter, lang)
  429. r.URL.RawQuery = q.Encode()
  430. }
  431. r.Header.Set(acceptLanguageHeaderKey, lang)
  432. }
  433. // Wrapper returns a new router wrapper.
  434. // The result function can be passed on `Application.WrapRouter/AddRouterWrapper`.
  435. // It compares the path prefix for translated language and
  436. // local redirects the requested path with the selected (from the path) language to the router.
  437. //
  438. // You do NOT have to call it manually, just set the `I18n.PathRedirect` field to true.
  439. func (i *I18n) Wrapper() router.WrapperFunc {
  440. if !i.PathRedirect {
  441. return nil
  442. }
  443. return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
  444. found := false
  445. path := r.URL.Path
  446. if len(path) > 0 && path[0] == '/' {
  447. path = path[1:]
  448. }
  449. if idx := strings.IndexByte(path, '/'); idx > 0 {
  450. path = path[:idx]
  451. }
  452. if path != "" {
  453. if tag, _, ok := i.TryMatchString(path); ok {
  454. lang := tag.String()
  455. path = r.URL.Path[len(path)+1:]
  456. if path == "" {
  457. path = "/"
  458. }
  459. r.RequestURI = path
  460. r.URL.Path = path
  461. i.setLangWithoutContext(w, r, lang)
  462. found = true
  463. }
  464. }
  465. if !found && i.Subdomain {
  466. host := context.GetHost(r)
  467. if dotIdx := strings.IndexByte(host, '.'); dotIdx > 0 {
  468. if subdomain := host[0:dotIdx]; subdomain != "" {
  469. if tag, _, ok := i.TryMatchString(subdomain); ok {
  470. host = host[dotIdx+1:]
  471. r.URL.Host = host
  472. r.Host = host
  473. i.setLangWithoutContext(w, r, tag.String())
  474. }
  475. }
  476. }
  477. }
  478. next(w, r)
  479. }
  480. }
  481. func removeDuplicates(elements []string) (result []string) {
  482. seen := make(map[string]struct{})
  483. for v := range elements {
  484. val := elements[v]
  485. if _, ok := seen[val]; !ok {
  486. seen[val] = struct{}{}
  487. result = append(result, val)
  488. }
  489. }
  490. return result
  491. }