123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585 |
- // Package i18n provides internalization and localization features for Iris.
- // To use with net/http see https://github.com/kataras/i18n instead.
- package i18n
- import (
- "fmt"
- "io/fs"
- "net/http"
- "os"
- "strings"
- "sync"
- "github.com/kataras/iris/v12/context"
- "github.com/kataras/iris/v12/core/router"
- "github.com/kataras/iris/v12/i18n/internal"
- "golang.org/x/text/language"
- )
- type (
- // MessageFunc is the function type to modify the behavior when a key or language was not found.
- // All language inputs fallback to the default locale if not matched.
- // This is why this signature accepts both input and matched languages, so caller
- // can provide better messages.
- //
- // The first parameter is set to the client real input of the language,
- // the second one is set to the matched language (default one if input wasn't matched)
- // and the third and forth are the translation format/key and its optional arguments.
- //
- // Note: we don't accept the Context here because Tr method and template func {{ tr }}
- // have no direct access to it.
- MessageFunc = internal.MessageFunc
- // Loader accepts a `Matcher` and should return a `Localizer`.
- // Functions that implement this type should load locale files.
- Loader func(m *Matcher) (Localizer, error)
- // Localizer is the interface which returned from a `Loader`.
- // Types that implement this interface should be able to retrieve a `Locale`
- // based on the language index.
- Localizer interface {
- // GetLocale should return a valid `Locale` based on the language index.
- // It will always match the Loader.Matcher.Languages[index].
- // It may return the default language if nothing else matches based on custom localizer's criteria.
- GetLocale(index int) context.Locale
- }
- )
- // I18n is the structure which keeps the i18n configuration and implements localization and internationalization features.
- type I18n struct {
- localizer Localizer
- matcher *Matcher
- Loader LoaderConfig
- loader Loader
- mu sync.Mutex
- // ExtractFunc is the type signature for declaring custom logic
- // to extract the language tag name.
- // Defaults to nil.
- ExtractFunc func(ctx *context.Context) string
- // DefaultMessageFunc is the field which can be used
- // to modify the behavior when a key or language was not found.
- // All language inputs fallback to the default locale if not matched.
- // This is why this one accepts both input and matched languages,
- // so the caller can be more expressful knowing those.
- //
- // Defaults to nil.
- DefaultMessageFunc MessageFunc
- // If not empty, it is language identifier by url query.
- //
- // Defaults to "lang".
- URLParameter string
- // If not empty, it is language identifier by cookie of this name.
- //
- // Defaults to empty.
- Cookie string
- // If true then a subdomain can be a language identifier.
- //
- // Defaults to true.
- Subdomain bool
- // If a DefaultMessageFunc is NOT set:
- // If true then it will return empty string when translation for a
- // specific language's key was not found.
- // Defaults to false, fallback defaultLang:key will be used.
- // Otherwise, DefaultMessageFunc is called in either case.
- Strict bool
- // If true then Iris will wrap its router with the i18n router wrapper on its Build state.
- // It will (local) redirect requests like:
- // 1. /$lang_prefix/$path to /$path with the language set to $lang_prefix part.
- // 2. $lang_subdomain.$domain/$path to $domain/$path with the language set to $lang_subdomain part.
- //
- // Defaults to true.
- PathRedirect bool
- }
- var _ context.I18nReadOnly = (*I18n)(nil)
- // makeTags converts language codes to language Tags.
- func makeTags(languages ...string) (tags []language.Tag) {
- languages = removeDuplicates(languages)
- for _, lang := range languages {
- tag, err := language.Parse(lang)
- if err == nil && tag != language.Und {
- tags = append(tags, tag)
- }
- }
- return
- }
- // New returns a new `I18n` instance. Use its `Load` or `LoadAssets` to load languages.
- // Examples at: https://github.com/kataras/iris/tree/main/_examples/i18n.
- func New() *I18n {
- i := &I18n{
- Loader: DefaultLoaderConfig,
- URLParameter: "lang",
- Subdomain: true,
- PathRedirect: true,
- }
- return i
- }
- // Load is a method shortcut to load files using a filepath.Glob pattern.
- // It returns a non-nil error on failure.
- //
- // See `New` and `Glob` package-level functions for more.
- func (i *I18n) Load(globPattern string, languages ...string) error {
- return i.Reset(Glob(globPattern, i.Loader), languages...)
- }
- // LoadAssets is a method shortcut to load files using go-bindata.
- // It returns a non-nil error on failure.
- //
- // See `New` and `Asset` package-level functions for more.
- func (i *I18n) LoadAssets(assetNames func() []string, asset func(string) ([]byte, error), languages ...string) error {
- return i.Reset(Assets(assetNames, asset, i.Loader), languages...)
- }
- // LoadFS is a method shortcut to load files using
- // an `embed.FS` or `fs.FS` or `http.FileSystem` value.
- // The "pattern" is a classic glob pattern.
- //
- // See `New` and `FS` package-level functions for more.
- // Example: https://github.com/kataras/iris/blob/main/_examples/i18n/template-embedded/main.go.
- func (i *I18n) LoadFS(fileSystem fs.FS, pattern string, languages ...string) error {
- loader, err := FS(fileSystem, pattern, i.Loader)
- if err != nil {
- return err
- }
- return i.Reset(loader, languages...)
- }
- // LoadKV is a method shortcut to load locales from a map of specified languages.
- // See `KV` package-level function for more.
- func (i *I18n) LoadKV(langMap LangMap, languages ...string) error {
- loader := KV(langMap, i.Loader)
- return i.Reset(loader, languages...)
- }
- // Reset sets the locales loader and languages.
- // It is not meant to be used by users unless
- // a custom `Loader` must be used instead of the default one.
- func (i *I18n) Reset(loader Loader, languages ...string) error {
- tags := makeTags(languages...)
- i.loader = loader
- i.matcher = &Matcher{
- strict: len(tags) > 0,
- Languages: tags,
- matcher: language.NewMatcher(tags),
- defaultMessageFunc: i.DefaultMessageFunc,
- }
- return i.reload()
- }
- // reload loads the language files from the provided Loader,
- // the `New` package-level function preloads those files already.
- func (i *I18n) reload() error { // May be an exported function, if requested.
- i.mu.Lock()
- defer i.mu.Unlock()
- if i.loader == nil {
- return fmt.Errorf("nil loader")
- }
- localizer, err := i.loader(i.matcher)
- if err != nil {
- return err
- }
- i.localizer = localizer
- return nil
- }
- // Loaded reports whether `New` or `Load/LoadAssets` called.
- func (i *I18n) Loaded() bool {
- return i != nil && i.loader != nil && i.localizer != nil && i.matcher != nil
- }
- // Tags returns the registered languages or dynamically resolved by files.
- // Use `Load` or `LoadAssets` first.
- func (i *I18n) Tags() []language.Tag {
- if !i.Loaded() {
- return nil
- }
- return i.matcher.Languages
- }
- // SetDefault changes the default language.
- // Please avoid using this method; the default behavior will accept
- // the first language of the registered tags as the default one.
- func (i *I18n) SetDefault(langCode string) bool {
- t, err := language.Parse(langCode)
- if err != nil {
- return false
- }
- if tag, index, conf := i.matcher.Match(t); conf > language.Low {
- if l, ok := i.localizer.(interface {
- SetDefault(int) bool
- }); ok {
- if l.SetDefault(index) {
- tags := i.matcher.Languages
- // set the order
- tags[index] = tags[0]
- tags[0] = tag
- i.matcher.Languages = tags
- i.matcher.matcher = language.NewMatcher(tags)
- return true
- }
- }
- }
- return false
- }
- // Matcher implements the languae.Matcher.
- // It contains the original language Matcher and keeps an ordered
- // list of the registered languages for further use (see `Loader` implementation).
- type Matcher struct {
- strict bool
- Languages []language.Tag
- matcher language.Matcher
- // defaultMessageFunc passed by the i18n structure.
- defaultMessageFunc MessageFunc
- }
- var _ language.Matcher = (*Matcher)(nil)
- // Match returns the best match for any of the given tags, along with
- // a unique index associated with the returned tag and a confidence
- // score.
- func (m *Matcher) Match(t ...language.Tag) (language.Tag, int, language.Confidence) {
- return m.matcher.Match(t...)
- }
- // MatchOrAdd acts like Match but it checks and adds a language tag, if not found,
- // when the `Matcher.strict` field is true (when no tags are provided by the caller)
- // and they should be dynamically added to the list.
- func (m *Matcher) MatchOrAdd(t language.Tag) (tag language.Tag, index int, conf language.Confidence) {
- tag, index, conf = m.Match(t)
- if conf <= language.Low && !m.strict {
- // not found, add it now.
- m.Languages = append(m.Languages, t)
- tag = t
- index = len(m.Languages) - 1
- conf = language.Exact
- m.matcher = language.NewMatcher(m.Languages) // reset matcher to include the new language.
- }
- return
- }
- // ParseLanguageFiles returns a map of language indexes and
- // their associated files based on the "fileNames".
- func (m *Matcher) ParseLanguageFiles(fileNames []string) (map[int][]string, error) {
- languageFiles := make(map[int][]string)
- for _, fileName := range fileNames {
- index := parsePath(m, fileName)
- if index == -1 {
- continue
- }
- languageFiles[index] = append(languageFiles[index], fileName)
- }
- return languageFiles, nil
- }
- func parsePath(m *Matcher, path string) int {
- if t, ok := parseLanguage(path); ok {
- if _, index, conf := m.MatchOrAdd(t); conf > language.Low {
- return index
- }
- }
- return -1
- }
- func parseLanguageName(m *Matcher, name string) int {
- if t, err := language.Parse(name); err == nil {
- if _, index, conf := m.MatchOrAdd(t); conf > language.Low {
- return index
- }
- }
- return -1
- }
- func reverseStrings(s []string) []string {
- for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
- s[i], s[j] = s[j], s[i]
- }
- return s
- }
- func parseLanguage(path string) (language.Tag, bool) {
- if idx := strings.LastIndexByte(path, '.'); idx > 0 {
- path = path[0:idx]
- }
- // path = strings.ReplaceAll(path, "..", "")
- names := strings.FieldsFunc(path, func(r rune) bool {
- return r == '_' || r == os.PathSeparator || r == '/' || r == '.'
- })
- names = reverseStrings(names) // see https://github.com/kataras/i18n/issues/1
- for _, s := range names {
- t, err := language.Parse(s)
- if err != nil {
- continue
- }
- return t, true
- }
- return language.Und, false
- }
- // TryMatchString will try to match the "s" with a registered language tag.
- // It returns -1 as the language index and false if not found.
- func (i *I18n) TryMatchString(s string) (language.Tag, int, bool) {
- if tag, err := language.Parse(s); err == nil {
- if tag, index, conf := i.matcher.Match(tag); conf > language.Low {
- return tag, index, true
- }
- }
- return language.Und, -1, false
- }
- // Tr returns a translated message based on the "lang" language code
- // and its key with any optional arguments attached to it.
- //
- // It returns an empty string if "lang" not matched, unless DefaultMessageFunc.
- // It returns the default language's translation if "key" not matched, unless DefaultMessageFunc.
- func (i *I18n) Tr(lang, key string, args ...interface{}) string {
- _, index, ok := i.TryMatchString(lang)
- if !ok {
- index = 0
- }
- loc := i.localizer.GetLocale(index)
- return i.getLocaleMessage(loc, lang, key, args...)
- }
- // TrContext returns the localized text message for this Context.
- // It returns an empty string if context's locale not matched, unless DefaultMessageFunc.
- // It returns the default language's translation if "key" not matched, unless DefaultMessageFunc.
- func (i *I18n) TrContext(ctx *context.Context, key string, args ...interface{}) string {
- loc := ctx.GetLocale()
- langInput := ctx.Values().GetString(ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey())
- return i.getLocaleMessage(loc, langInput, key, args...)
- }
- func (i *I18n) getLocaleMessage(loc context.Locale, langInput string, key string, args ...interface{}) (msg string) {
- langMatched := ""
- if loc != nil {
- langMatched = loc.Language()
- msg = loc.GetMessage(key, args...)
- if msg == "" && i.DefaultMessageFunc == nil && !i.Strict && loc.Index() > 0 {
- // it's not the default/fallback language and not message found for that lang:key.
- msg = i.localizer.GetLocale(0).GetMessage(key, args...)
- }
- }
- if msg == "" && i.DefaultMessageFunc != nil {
- msg = i.DefaultMessageFunc(langInput, langMatched, key, args...)
- }
- return
- }
- const acceptLanguageHeaderKey = "Accept-Language"
- // GetLocale returns the found locale of a request.
- // It will return the first registered language if nothing else matched.
- func (i *I18n) GetLocale(ctx *context.Context) context.Locale {
- var (
- index int
- ok bool
- extractedLang string
- )
- languageInputKey := ctx.Application().ConfigurationReadOnly().GetLanguageInputContextKey()
- if contextKey := ctx.Application().ConfigurationReadOnly().GetLanguageContextKey(); contextKey != "" {
- if v := ctx.Values().GetString(contextKey); v != "" {
- if languageInputKey != "" {
- ctx.Values().Set(languageInputKey, v)
- }
- if v == "default" {
- index = 0 // no need to call `TryMatchString` and spend time.
- } else {
- _, index, _ = i.TryMatchString(v)
- }
- locale := i.localizer.GetLocale(index)
- if locale == nil {
- return nil
- }
- return locale
- }
- }
- if !ok && i.ExtractFunc != nil {
- if v := i.ExtractFunc(ctx); v != "" {
- extractedLang = v
- _, index, ok = i.TryMatchString(v)
- }
- }
- if !ok && i.URLParameter != "" {
- if v := ctx.URLParam(i.URLParameter); v != "" {
- extractedLang = v
- _, index, ok = i.TryMatchString(v)
- }
- }
- if !ok && i.Cookie != "" {
- if v := ctx.GetCookie(i.Cookie); v != "" {
- extractedLang = v
- _, index, ok = i.TryMatchString(v) // url.QueryUnescape(cookie.Value)
- }
- }
- if !ok && i.Subdomain {
- if v := ctx.Subdomain(); v != "" {
- extractedLang = v
- _, index, ok = i.TryMatchString(v)
- }
- }
- if !ok {
- if v := ctx.GetHeader(acceptLanguageHeaderKey); v != "" {
- extractedLang = v // note.
- desired, _, err := language.ParseAcceptLanguage(v)
- if err == nil {
- if _, idx, conf := i.matcher.Match(desired...); conf > language.Low {
- index = idx
- }
- }
- }
- }
- // locale := i.localizer.GetLocale(index)
- // ctx.Values().Set(ctx.Application().ConfigurationReadOnly().GetLocaleContextKey(), locale)
- if languageInputKey != "" {
- // Set the user input we wanna use it on DefaultMessageFunc.
- // Even if matched because it may be en-gb or en but if there is a language registered
- // as en-us it will be successfully matched ( see TrymatchString and Low conf).
- ctx.Values().Set(languageInputKey, extractedLang)
- }
- // if index == 0 then it defaults to the first language.
- locale := i.localizer.GetLocale(index)
- if locale == nil {
- return nil
- }
- return locale
- }
- func (i *I18n) setLangWithoutContext(w http.ResponseWriter, r *http.Request, lang string) {
- if i.Cookie != "" {
- http.SetCookie(w, &http.Cookie{
- Name: i.Cookie,
- Value: lang,
- // allow subdomain sharing.
- Domain: context.GetDomain(context.GetHost(r)),
- SameSite: http.SameSiteLaxMode,
- })
- } else if i.URLParameter != "" {
- q := r.URL.Query()
- q.Set(i.URLParameter, lang)
- r.URL.RawQuery = q.Encode()
- }
- r.Header.Set(acceptLanguageHeaderKey, lang)
- }
- // Wrapper returns a new router wrapper.
- // The result function can be passed on `Application.WrapRouter/AddRouterWrapper`.
- // It compares the path prefix for translated language and
- // local redirects the requested path with the selected (from the path) language to the router.
- //
- // You do NOT have to call it manually, just set the `I18n.PathRedirect` field to true.
- func (i *I18n) Wrapper() router.WrapperFunc {
- if !i.PathRedirect {
- return nil
- }
- return func(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
- found := false
- path := r.URL.Path
- if len(path) > 0 && path[0] == '/' {
- path = path[1:]
- }
- if idx := strings.IndexByte(path, '/'); idx > 0 {
- path = path[:idx]
- }
- if path != "" {
- if tag, _, ok := i.TryMatchString(path); ok {
- lang := tag.String()
- path = r.URL.Path[len(path)+1:]
- if path == "" {
- path = "/"
- }
- r.RequestURI = path
- r.URL.Path = path
- i.setLangWithoutContext(w, r, lang)
- found = true
- }
- }
- if !found && i.Subdomain {
- host := context.GetHost(r)
- if dotIdx := strings.IndexByte(host, '.'); dotIdx > 0 {
- if subdomain := host[0:dotIdx]; subdomain != "" {
- if tag, _, ok := i.TryMatchString(subdomain); ok {
- host = host[dotIdx+1:]
- r.URL.Host = host
- r.Host = host
- i.setLangWithoutContext(w, r, tag.String())
- }
- }
- }
- }
- next(w, r)
- }
- }
- func removeDuplicates(elements []string) (result []string) {
- seen := make(map[string]struct{})
- for v := range elements {
- val := elements[v]
- if _, ok := seen[val]; !ok {
- seen[val] = struct{}{}
- result = append(result, val)
- }
- }
- return result
- }
|